From de609b4403d9ac3771686e0bdcd2d9a3a783833f Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Fri, 16 Oct 2026 20:13:45 +0900 Subject: [PATCH 01/33] fix(photography): respect Core lightbox safe areas in RTL and mobile --- .github/workflows/photography.yml | 2 +- assets/css/photography.css | 2 +- docs/RTL-LIGHTBOX-SAFE-AREA.md | 10 ++ tests/browser/lightbox-rtl-safe-area.spec.js | 133 +++++++++++++++++++ tests/test_lightbox_rtl_contract.py | 46 +++++++ tests/verify_gallery_ci_results.py | 4 + tests/verify_gallery_screenshots.py | 3 + 7 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 docs/RTL-LIGHTBOX-SAFE-AREA.md create mode 100644 tests/browser/lightbox-rtl-safe-area.spec.js create mode 100644 tests/test_lightbox_rtl_contract.py diff --git a/.github/workflows/photography.yml b/.github/workflows/photography.yml index 6ac3a466..807742d1 100644 --- a/.github/workflows/photography.yml +++ b/.github/workflows/photography.yml @@ -114,7 +114,7 @@ jobs: : "${SLATEFRAME_PLAIN_GALLERY_PATH:?Missing plain Gallery fixture}" rm -rf test-artifacts/photography-lightbox mkdir -p test-artifacts/photography-lightbox - npx playwright test --reporter=list,html,json tests/browser/photography-raster-lightbox.spec.js tests/browser/photography-native-sequence.spec.js tests/browser/photography-class-only.spec.js tests/browser/photography-zoom-caption.spec.js + npx playwright test --reporter=list,html,json tests/browser/photography-raster-lightbox.spec.js tests/browser/photography-native-sequence.spec.js tests/browser/photography-class-only.spec.js tests/browser/photography-zoom-caption.spec.js tests/browser/lightbox-rtl-safe-area.spec.js - name: Require executed Gallery test coverage run: python3 tests/verify_gallery_ci_results.py - name: Require exact-SHA Gallery screenshot evidence diff --git a/assets/css/photography.css b/assets/css/photography.css index 94097244..f8bbf3b3 100644 --- a/assets/css/photography.css +++ b/assets/css/photography.css @@ -1 +1 @@ -html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:max(var(--slateframe-component-gap),env(safe-area-inset-top,0px));margin-inline-end:max(var(--slateframe-component-gap),env(safe-area-inset-right,0px))}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap)}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence) .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence) figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}} \ No newline at end of file +html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:var(--slateframe-component-gap);margin-inline-end:var(--slateframe-component-gap)}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap);& .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence) figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button{right:auto;left:calc(env(safe-area-inset-left,0px) + var(--slateframe-space-4));margin-inline-end:0}@media(max-width:959.98px){body .wp-lightbox-overlay .wp-lightbox-navigation-button{bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))}} \ No newline at end of file diff --git a/docs/RTL-LIGHTBOX-SAFE-AREA.md b/docs/RTL-LIGHTBOX-SAFE-AREA.md new file mode 100644 index 00000000..f13a5be7 --- /dev/null +++ b/docs/RTL-LIGHTBOX-SAFE-AREA.md @@ -0,0 +1,10 @@ +# Native WordPress Core Lightbox: RTL and safe-area presentation + +Slateframe keeps Core's dialog, navigation, keyboard handling, image selection, and focus restoration. No custom lightbox JavaScript is added. + +- **Close control:** Core positions the close button using physical `right` and already includes top/right safe-area insets. Slateframe adds only its component gap in LTR. In RTL, the button is anchored to the physical left with `safe-area-inset-left`, and the inherited logical-end margin is reset. Never double-count Core's top safe area. +- **Gallery navigation:** Below Core's 960px breakpoint, previous/next buttons retain Core's spacing token and also respect `safe-area-inset-bottom`. At 960px and above, Core's vertically centered controls remain unchanged. +- **Gallery CSS:** Reuse the existing gallery style selector, preserving the 6000-byte Photography CSS budget. +- **Evidence:** Real WordPress and Chromium must test both directions at 320/375/390/412/768/1440/1920, 44px targets, Escape/focus restoration, Gallery navigation, and the 959/960 boundary. Representative screenshots must be tied to the exact Actions SHA. + +Do not mark this change production validated until Theme quality and Photography Actions pass for the new HEAD and the real WordPress screenshots have been inspected. diff --git a/tests/browser/lightbox-rtl-safe-area.spec.js b/tests/browser/lightbox-rtl-safe-area.spec.js new file mode 100644 index 00000000..37dbaf01 --- /dev/null +++ b/tests/browser/lightbox-rtl-safe-area.spec.js @@ -0,0 +1,133 @@ +const { test, expect } = require('@playwright/test'); +const fs = require('node:fs/promises'); + +const fixture = process.env.SLATEFRAME_RASTER_PHOTO_PATH; +const evidenceWidths = [320, 390, 768, 1440, 1920]; +const width = (info) => Number(info.project.name.replace('viewport-', '')); +test.skip(!fixture, 'Requires real WordPress raster photography fixture.'); + +async function open(page, direction, gallery = false) { + await page.goto(fixture, { waitUntil: 'domcontentloaded' }); + await page.evaluate((dir) => { document.documentElement.dir = dir; }, direction); + const scope = gallery ? '#main-content .browser-raster-gallery' : '#main-content'; + const trigger = page.locator(scope + ' button.wp-lightbox-container, ' + scope + ' .wp-lightbox-container button').first(); + await expect(trigger).toBeVisible(); + await trigger.focus(); + await expect(trigger).toBeFocused(); + await page.keyboard.press('Enter'); + const dialog = page.locator('[role="dialog"]').last(); + await expect(dialog).toBeVisible(); + return { trigger, dialog, close: dialog.locator('.wp-lightbox-close-button') }; +} + +async function capture(page, name) { + await fs.mkdir('test-artifacts/photography-lightbox', { recursive: true }); + await page.screenshot({ path: 'test-artifacts/photography-lightbox/' + name, fullPage: false, animations: 'disabled' }); +} + +async function verifyClose(page, info, direction) { + const { trigger, dialog, close } = await open(page, direction); + await expect(close).toBeVisible(); + const m = await close.evaluate((node) => { + const r = node.getBoundingClientRect(), style = getComputedStyle(node); + const probe = document.createElement('span'); + probe.style.cssText = 'position:absolute;left:var(--slateframe-space-4);margin-top:var(--slateframe-component-gap)'; + document.body.append(probe); + const offset = parseFloat(getComputedStyle(probe).left); + const gap = parseFloat(getComputedStyle(probe).marginTop); + probe.remove(); + return { left:r.left, right:r.right, top:r.top, bottom:r.bottom, width:r.width, height:r.height, + direction:style.direction, margin:parseFloat(style.marginBlockStart), offset, gap, + vw:document.documentElement.clientWidth, vh:document.documentElement.clientHeight, + overflow:document.documentElement.scrollWidth-document.documentElement.clientWidth }; + }); + expect(m.direction).toBe(direction); + expect(m.width).toBeGreaterThanOrEqual(44); + expect(m.height).toBeGreaterThanOrEqual(44); + expect(m.left).toBeGreaterThanOrEqual(-1); + expect(m.right).toBeLessThanOrEqual(m.vw + 1); + expect(m.top).toBeGreaterThanOrEqual(-1); + expect(m.bottom).toBeLessThanOrEqual(m.vh + 1); + expect(m.overflow).toBeLessThanOrEqual(1); + expect(Math.abs(m.margin - m.gap)).toBeLessThanOrEqual(1); + if (direction === 'rtl') { + expect(m.left).toBeLessThan(m.vw - m.right); + expect(m.left).toBeGreaterThanOrEqual(m.offset - 1); + if (evidenceWidths.includes(width(info))) await capture(page, 'lightbox-rtl-close-viewport-' + width(info) + '.png'); + } else { + expect(m.vw - m.right).toBeLessThan(m.left); + } + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); +} + +test('native Core lightbox close control follows RTL inline-end safe area and restores focus', async ({page}, info) => { + await verifyClose(page, info, 'rtl'); +}); + +test('native Core lightbox close control retains LTR right-edge placement', async ({page}, info) => { + await verifyClose(page, info, 'ltr'); +}); + +test('native Core gallery navigation respects mobile bottom safe area and desktop Core placement', async ({page}, info) => { + for (const direction of ['ltr', 'rtl']) { + // Standalone images have no navigation: select the two-image Core Gallery. + const { trigger, dialog } = await open(page, direction, true); + const navigation = dialog.locator('.wp-lightbox-navigation-button:visible'); + await expect(navigation).toHaveCount(2); + for (const button of await navigation.all()) { + const m = await button.evaluate((node) => { + const r = node.getBoundingClientRect(), style = getComputedStyle(node); + const probe = document.createElement('span'); + probe.style.cssText = 'position:absolute;bottom:var(--slateframe-space-4)'; + document.body.append(probe); + const gap = parseFloat(getComputedStyle(probe).bottom); + probe.remove(); + return { left:r.left, right:r.right, width:r.width, height:r.height, + bottomGap:document.documentElement.clientHeight-r.bottom, + bottom:parseFloat(style.bottom), transform:style.transform, gap, + vw:document.documentElement.clientWidth, vh:document.documentElement.clientHeight }; + }); + expect(m.width).toBeGreaterThanOrEqual(44); + expect(m.height).toBeGreaterThanOrEqual(44); + expect(m.left).toBeGreaterThanOrEqual(-1); + expect(m.right).toBeLessThanOrEqual(m.vw + 1); + if (width(info) < 960) { + expect(Math.abs(m.bottomGap - m.gap)).toBeLessThanOrEqual(2); + } else { + expect(Math.abs(m.bottom - m.vh / 2)).toBeLessThanOrEqual(2); + expect(m.transform).not.toBe('none'); + } + } + if (evidenceWidths.includes(width(info))) await capture(page, 'lightbox-navigation-' + direction + '-viewport-' + width(info) + '.png'); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + } +}); + +test('native Core navigation preserves its 959px/960px breakpoint', async ({page}, info) => { + test.skip(width(info) !== 390, 'Boundary evidence is collected in the 390px project.'); + const { dialog } = await open(page, 'rtl', true); + const button = dialog.locator('.wp-lightbox-navigation-button:visible').first(); + await expect(button).toBeVisible(); + for (const w of [959, 960]) { + await page.setViewportSize({ width:w, height:800 }); + const m = await button.evaluate((node) => ({ + bottom:parseFloat(getComputedStyle(node).bottom), + transform:getComputedStyle(node).transform, + height:document.documentElement.clientHeight + })); + if (w === 959) { + expect(m.bottom).toBeGreaterThan(0); + expect(m.bottom).toBeLessThan(m.height / 4); + } else { + expect(Math.abs(m.bottom - m.height / 2)).toBeLessThanOrEqual(2); + expect(m.transform).not.toBe('none'); + } + await capture(page, 'lightbox-navigation-breakpoint-' + w + '.png'); + } + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +}); diff --git a/tests/test_lightbox_rtl_contract.py b/tests/test_lightbox_rtl_contract.py new file mode 100644 index 00000000..5f5f94a4 --- /dev/null +++ b/tests/test_lightbox_rtl_contract.py @@ -0,0 +1,46 @@ +"""Guard Core Lightbox presentation and executed CI evidence.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CSS = (ROOT / 'assets/css/photography.css').read_text() +TEST = (ROOT / 'tests/browser/lightbox-rtl-safe-area.spec.js').read_text() +WORKFLOW = (ROOT / '.github/workflows/photography.yml').read_text() +COVERAGE = (ROOT / 'tests/verify_gallery_ci_results.py').read_text() +EVIDENCE = (ROOT / 'tests/verify_gallery_screenshots.py').read_text() + + +class NativeLightboxContract(unittest.TestCase): + def test_rtl_reanchors_without_double_counting_safe_area(self): + self.assertIn('.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button', CSS) + self.assertIn('right:auto;left:calc(env(safe-area-inset-left,0px)', CSS) + self.assertIn('margin-inline-end:0', CSS) + self.assertIn('margin-block-start:var(--slateframe-component-gap)', CSS) + self.assertNotIn('margin-block-start:max(', CSS) + + def test_mobile_navigation_preserves_core_desktop_breakpoint(self): + self.assertIn('@media(max-width:959.98px)', CSS) + self.assertIn('bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))', CSS) + self.assertIn('page.setViewportSize({ width:w, height:800 })', TEST) + self.assertIn("for (const direction of ['ltr', 'rtl'])", TEST) + + def test_gallery_rule_reuses_existing_selector_and_budget(self): + self.assertIn('gap:var(--slateframe-media-gap);& .wp-block-image img{', CSS) + self.assertLessEqual(len(CSS.encode('utf-8')), 6000) + + def test_keyboard_and_target_size_are_covered(self): + self.assertIn('browser-raster-gallery', TEST) + self.assertIn('toHaveCount(2)', TEST) + self.assertIn('toBeGreaterThanOrEqual(44)', TEST) + self.assertIn("page.keyboard.press('Escape')", TEST) + self.assertIn('toBeFocused()', TEST) + + def test_ci_requires_real_tests_and_screenshots(self): + self.assertIn('tests/browser/lightbox-rtl-safe-area.spec.js', WORKFLOW) + self.assertIn('native Core navigation preserves its 959px/960px breakpoint', COVERAGE) + self.assertIn('lightbox-navigation-breakpoint-', EVIDENCE) + self.assertIn('lightbox-rtl-close-viewport-', EVIDENCE) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/verify_gallery_ci_results.py b/tests/verify_gallery_ci_results.py index 75c274b3..ca59e119 100644 --- a/tests/verify_gallery_ci_results.py +++ b/tests/verify_gallery_ci_results.py @@ -15,6 +15,10 @@ REQUIRED = {(filename, title, f'viewport-{width}') for filename, title in SUITES.items() if filename != 'photography-zoom-caption.spec.js' for width in VIEWPORTS} REQUIRED.update({ + *{('lightbox-rtl-safe-area.spec.js', 'native Core lightbox close control follows RTL inline-end safe area and restores focus', f'viewport-{w}') for w in VIEWPORTS}, + *{('lightbox-rtl-safe-area.spec.js', 'native Core lightbox close control retains LTR right-edge placement', f'viewport-{w}') for w in VIEWPORTS}, + *{('lightbox-rtl-safe-area.spec.js', 'native Core gallery navigation respects mobile bottom safe area and desktop Core placement', f'viewport-{w}') for w in VIEWPORTS}, + ('lightbox-rtl-safe-area.spec.js', 'native Core navigation preserves its 959px/960px breakpoint', 'viewport-390'), *{('photography-native-sequence.spec.js', 'two-frame Gallery centers final image in LTR and RTL at every viewport', f'viewport-{w}') for w in VIEWPORTS}, *{('photography-class-only.spec.js', 'two-frame Gallery centers final image in LTR and RTL at every viewport', f'viewport-{w}') for w in VIEWPORTS}, ('photography-raster-lightbox.spec.js', 'raster photography exposes responsive candidates and stable intrinsic geometry', 'viewport-390'), diff --git a/tests/verify_gallery_screenshots.py b/tests/verify_gallery_screenshots.py index e796c50d..d6c99568 100644 --- a/tests/verify_gallery_screenshots.py +++ b/tests/verify_gallery_screenshots.py @@ -17,6 +17,9 @@ **{f'native-sequence-page-{w}.png': w for w in WIDTHS}, **{f'photography-page-viewport-{w}.png': w for w in WIDTHS}, **{f'class-only-sequence-{w}.png': w for w in (390, 1440)}, + **{f'lightbox-rtl-close-viewport-{w}.png': w for w in WIDTHS}, + **{f'lightbox-navigation-{direction}-viewport-{w}.png': w for w in WIDTHS for direction in ('ltr', 'rtl')}, + **{f'lightbox-navigation-breakpoint-{w}.png': w for w in (959, 960)}, **{f'lightbox-landscape-viewport-{w}.png': w for w in WIDTHS}, **{f'lightbox-portrait-viewport-{w}.png': w for w in WIDTHS}, **{f'zoom-caption-{kind}-{direction}-360x225.png': 360 From 660262c033a84a23924c171c7c8535c3ef54e79e Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Fri, 16 Oct 2026 20:17:22 +0900 Subject: [PATCH 02/33] fix(photography): preserve gallery selector parity within aggregate CSS budget --- assets/css/photography.css | 2 +- tests/test_photo_class_only_assets.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/css/photography.css b/assets/css/photography.css index f8bbf3b3..e1bd606e 100644 --- a/assets/css/photography.css +++ b/assets/css/photography.css @@ -1 +1 @@ -html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:var(--slateframe-component-gap);margin-inline-end:var(--slateframe-component-gap)}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap);& .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence) figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button{right:auto;left:calc(env(safe-area-inset-left,0px) + var(--slateframe-space-4));margin-inline-end:0}@media(max-width:959.98px){body .wp-lightbox-overlay .wp-lightbox-navigation-button{bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))}} \ No newline at end of file +html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:var(--slateframe-component-gap);margin-inline-end:var(--slateframe-component-gap)}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap);& .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}&.has-nested-images figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button{right:auto;left:calc(env(safe-area-inset-left,0px) + var(--slateframe-space-4));margin-inline-end:0}@media(max-width:959.98px){body .wp-lightbox-overlay .wp-lightbox-navigation-button{bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))}} \ No newline at end of file diff --git a/tests/test_photo_class_only_assets.py b/tests/test_photo_class_only_assets.py index ecaa42d8..505a90ae 100644 --- a/tests/test_photo_class_only_assets.py +++ b/tests/test_photo_class_only_assets.py @@ -20,13 +20,15 @@ def test_registered_block_style_remains_supported(self): self.assertIn("'slateframe-photography-sequence'", self.php) def test_bounded_images_share_selector(self): - self.assertIn('.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence) .wp-block-image img', self.css) + self.assertIn('.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:', self.css) + self.assertIn('gap:var(--slateframe-media-gap);& .wp-block-image img{', self.css) self.assertIn('max-block-size:min(62dvh,42rem)', self.css) self.assertIn('aspect-ratio:auto', self.css) self.assertIn('object-fit:contain', self.css) def test_editor_caption_parity(self): - self.assertIn('.slateframe-gallery-sequence) figure.wp-block-image figcaption', self.css) + self.assertIn('&.has-nested-images figure.wp-block-image figcaption{', self.css) + self.assertIn('overflow-wrap:anywhere', self.css) self.assertIn('.slateframe-gallery-sequence) .wp-block-image figcaption', self.editor) def test_no_theme_boundary_regression(self): From 10e9945f4e0a7425b6da475e5105d79cf7034129 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Fri, 16 Oct 2026 20:20:32 +0900 Subject: [PATCH 03/33] fix(photography): mirror Core lightbox inline-end spacing in RTL --- assets/css/photography.css | 2 +- docs/RTL-LIGHTBOX-SAFE-AREA.md | 2 +- tests/browser/lightbox-rtl-safe-area.spec.js | 3 ++- tests/test_lightbox_rtl_contract.py | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/assets/css/photography.css b/assets/css/photography.css index e1bd606e..ee490cae 100644 --- a/assets/css/photography.css +++ b/assets/css/photography.css @@ -1 +1 @@ -html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:var(--slateframe-component-gap);margin-inline-end:var(--slateframe-component-gap)}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap);& .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}&.has-nested-images figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button{right:auto;left:calc(env(safe-area-inset-left,0px) + var(--slateframe-space-4));margin-inline-end:0}@media(max-width:959.98px){body .wp-lightbox-overlay .wp-lightbox-navigation-button{bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))}} \ No newline at end of file +html:has(.wp-lightbox-overlay.active){overflow:hidden;overscroll-behavior:none}.wp-lightbox-overlay{overflow:clip;background:var(--slateframe-bg);color:var(--slateframe-text);& :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);border:var(--slateframe-border-width) solid transparent!important;border-radius:var(--slateframe-radius);background:var(--slateframe-media-chrome)!important;color:var(--slateframe-media-chrome-text);fill:var(--slateframe-media-chrome-text)!important;box-sizing:border-box;display:grid;place-items:center;padding:0;touch-action:manipulation}& .wp-lightbox-close-button{margin-block-start:var(--slateframe-component-gap);margin-inline-end:var(--slateframe-component-gap)}& .lightbox-image-container{min-inline-size:0;max-inline-size:calc(100dvi - (2 * var(--slateframe-space-4)) - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));max-block-size:calc(100dvb - (2 * var(--slateframe-space-4)) - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))}& .wp-block-image{min-inline-size:0;max-inline-size:100%}& .wp-block-image img[sizes="100vw"]{inline-size:auto!important;block-size:auto!important;min-inline-size:0!important;min-block-size:0!important;max-inline-size:100%!important;max-block-size:100%!important;object-fit:contain!important}}@media (forced-colors:active){.wp-lightbox-overlay :is(.wp-lightbox-close-button,.wp-lightbox-navigation-button-prev,.wp-lightbox-navigation-button-next){border-color:ButtonText!important;background:Canvas!important;color:CanvasText!important;fill:ButtonText!important;forced-color-adjust:auto}}.wp-block-gallery:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-diptych,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){min-inline-size:0;max-inline-size:100%;gap:var(--slateframe-media-gap);& .wp-block-image img{inline-size:auto;max-inline-size:100%;block-size:auto;max-block-size:min(62dvh,42rem);margin-inline:auto;aspect-ratio:auto;object-fit:contain}&.has-nested-images figure.wp-block-image figcaption{position:static;max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) 0 0;overflow:visible;overflow-wrap:anywhere;background:none;color:var(--slateframe-muted);font-size:.875rem;line-height:1.55;text-align:start;text-shadow:none;font-weight:400}}.wp-block-gallery.is-style-slateframe-contact-sheet{align-items:flex-start}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-contact-sheet,.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{min-inline-size:0;max-inline-size:100%;align-self:start;flex-direction:column;justify-content:flex-start;background:var(--slateframe-surface-soft)}.wp-block-image.is-style-slateframe-photo-feature{margin-block:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap));background:var(--slateframe-media-chrome);& img{inline-size:100%;max-block-size:88dvh;object-fit:contain}& figcaption{max-inline-size:100%;margin:0;padding:var(--slateframe-caption-gap) var(--slateframe-component-gap);color:var(--slateframe-media-chrome-text);background:var(--slateframe-media-chrome);font-size:.875rem;line-height:1.55;text-align:start;overflow-wrap:anywhere}}.wp-lightbox-overlay .wp-block-image.is-style-slateframe-photo-feature{margin:0;background:none}.slateframe-photo-contact-sheet .wp-block-gallery,.slateframe-photography-feature .wp-block-image{margin-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.wp-block-gallery.is-style-slateframe-diptych{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:center}.wp-block-gallery.is-style-slateframe-diptych .wp-block-image{inline-size:100%!important;margin:0!important}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;&>.wp-block-image:first-child,&>.wp-block-image:nth-child(2):last-child{grid-column:1/-1}& img{inline-size:auto!important;block-size:auto!important;aspect-ratio:auto!important;object-fit:contain!important;flex:none!important}}.wp-block-gallery.has-nested-images:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>figure.wp-block-image{inline-size:fit-content!important;min-inline-size:min(100%,16ch);margin:0!important;justify-self:center;& figcaption{inline-size:0;min-inline-size:100%}@media(max-width:640px) and (max-height:480px){inline-size:min(100%,24ch)!important;& figcaption{inline-size:100%;min-inline-size:0}}}@media (max-width:781px){.wp-block-image.is-style-slateframe-photo-feature img{max-block-size:78dvh}}@media (max-width:540px){.wp-block-gallery.is-style-slateframe-diptych{grid-template-columns:1fr}}@media (max-width:640px){.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence){grid-template-columns:minmax(0,1fr)}.wp-block-gallery:is(.is-style-slateframe-photo-sequence,.slateframe-gallery-sequence)>.wp-block-image:first-child{grid-column:auto}}@media(max-width:420px){.wp-block-gallery.is-style-slateframe-contact-sheet{display:grid!important;grid-template-columns:1fr}.is-style-slateframe-contact-sheet>figure{width:100%!important}}@media (prefers-reduced-motion:reduce){.wp-lightbox-overlay,.wp-lightbox-overlay *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button{right:auto;left:calc(env(safe-area-inset-left,0px) + var(--slateframe-space-4))}@media(max-width:959.98px){body .wp-lightbox-overlay .wp-lightbox-navigation-button{bottom:calc(var(--slateframe-space-4) + env(safe-area-inset-bottom,0px))}} \ No newline at end of file diff --git a/docs/RTL-LIGHTBOX-SAFE-AREA.md b/docs/RTL-LIGHTBOX-SAFE-AREA.md index f13a5be7..b43c6044 100644 --- a/docs/RTL-LIGHTBOX-SAFE-AREA.md +++ b/docs/RTL-LIGHTBOX-SAFE-AREA.md @@ -2,7 +2,7 @@ Slateframe keeps Core's dialog, navigation, keyboard handling, image selection, and focus restoration. No custom lightbox JavaScript is added. -- **Close control:** Core positions the close button using physical `right` and already includes top/right safe-area insets. Slateframe adds only its component gap in LTR. In RTL, the button is anchored to the physical left with `safe-area-inset-left`, and the inherited logical-end margin is reset. Never double-count Core's top safe area. +- **Close control:** Core positions the close button using physical `right` and already includes top/right safe-area insets. Slateframe adds only its component gap in LTR. In RTL, the button is anchored to the physical left with `safe-area-inset-left`, and retains the shared logical-end component margin, giving LTR/RTL the same spacing from their respective safe-area edges. Never double-count Core's top safe area. - **Gallery navigation:** Below Core's 960px breakpoint, previous/next buttons retain Core's spacing token and also respect `safe-area-inset-bottom`. At 960px and above, Core's vertically centered controls remain unchanged. - **Gallery CSS:** Reuse the existing gallery style selector, preserving the 6000-byte Photography CSS budget. - **Evidence:** Real WordPress and Chromium must test both directions at 320/375/390/412/768/1440/1920, 44px targets, Escape/focus restoration, Gallery navigation, and the 959/960 boundary. Representative screenshots must be tied to the exact Actions SHA. diff --git a/tests/browser/lightbox-rtl-safe-area.spec.js b/tests/browser/lightbox-rtl-safe-area.spec.js index 37dbaf01..36724099 100644 --- a/tests/browser/lightbox-rtl-safe-area.spec.js +++ b/tests/browser/lightbox-rtl-safe-area.spec.js @@ -52,10 +52,11 @@ async function verifyClose(page, info, direction) { expect(Math.abs(m.margin - m.gap)).toBeLessThanOrEqual(1); if (direction === 'rtl') { expect(m.left).toBeLessThan(m.vw - m.right); - expect(m.left).toBeGreaterThanOrEqual(m.offset - 1); + expect(m.left).toBeGreaterThanOrEqual(m.offset + m.gap - 1); if (evidenceWidths.includes(width(info))) await capture(page, 'lightbox-rtl-close-viewport-' + width(info) + '.png'); } else { expect(m.vw - m.right).toBeLessThan(m.left); + expect(m.vw - m.right).toBeGreaterThanOrEqual(m.offset + m.gap - 1); } await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); diff --git a/tests/test_lightbox_rtl_contract.py b/tests/test_lightbox_rtl_contract.py index 5f5f94a4..571f45bc 100644 --- a/tests/test_lightbox_rtl_contract.py +++ b/tests/test_lightbox_rtl_contract.py @@ -14,7 +14,8 @@ class NativeLightboxContract(unittest.TestCase): def test_rtl_reanchors_without_double_counting_safe_area(self): self.assertIn('.wp-lightbox-overlay:dir(rtl) .wp-lightbox-close-button', CSS) self.assertIn('right:auto;left:calc(env(safe-area-inset-left,0px)', CSS) - self.assertIn('margin-inline-end:0', CSS) + self.assertIn('margin-inline-end:var(--slateframe-component-gap)', CSS) + self.assertNotIn('margin-inline-end:0}', CSS) self.assertIn('margin-block-start:var(--slateframe-component-gap)', CSS) self.assertNotIn('margin-block-start:max(', CSS) From 5c9d563aa9937c7c9c195f3bd0522a25b2c52ecf Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Fri, 16 Oct 2026 20:26:44 +0900 Subject: [PATCH 04/33] test(photography): capture distinct Core Gallery navigation evidence --- docs/RTL-LIGHTBOX-SAFE-AREA.md | 2 +- tests/browser/lightbox-rtl-safe-area.spec.js | 6 ++++++ tests/test_lightbox_rtl_contract.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/RTL-LIGHTBOX-SAFE-AREA.md b/docs/RTL-LIGHTBOX-SAFE-AREA.md index b43c6044..28a871a9 100644 --- a/docs/RTL-LIGHTBOX-SAFE-AREA.md +++ b/docs/RTL-LIGHTBOX-SAFE-AREA.md @@ -5,6 +5,6 @@ Slateframe keeps Core's dialog, navigation, keyboard handling, image selection, - **Close control:** Core positions the close button using physical `right` and already includes top/right safe-area insets. Slateframe adds only its component gap in LTR. In RTL, the button is anchored to the physical left with `safe-area-inset-left`, and retains the shared logical-end component margin, giving LTR/RTL the same spacing from their respective safe-area edges. Never double-count Core's top safe area. - **Gallery navigation:** Below Core's 960px breakpoint, previous/next buttons retain Core's spacing token and also respect `safe-area-inset-bottom`. At 960px and above, Core's vertically centered controls remain unchanged. - **Gallery CSS:** Reuse the existing gallery style selector, preserving the 6000-byte Photography CSS budget. -- **Evidence:** Real WordPress and Chromium must test both directions at 320/375/390/412/768/1440/1920, 44px targets, Escape/focus restoration, Gallery navigation, and the 959/960 boundary. Representative screenshots must be tied to the exact Actions SHA. +- **Evidence:** Real WordPress and Chromium must test both directions at 320/375/390/412/768/1440/1920, 44px targets, Escape/focus restoration, Gallery navigation, and the 959/960 boundary. Representative screenshots must be tied to the exact Actions SHA. Navigation evidence must depict the *square* frame after a real Next action, not duplicate the existing portrait screenshot; the test then returns to the portrait frame before Escape/focus verification. Do not mark this change production validated until Theme quality and Photography Actions pass for the new HEAD and the real WordPress screenshots have been inspected. diff --git a/tests/browser/lightbox-rtl-safe-area.spec.js b/tests/browser/lightbox-rtl-safe-area.spec.js index 36724099..65ae42c9 100644 --- a/tests/browser/lightbox-rtl-safe-area.spec.js +++ b/tests/browser/lightbox-rtl-safe-area.spec.js @@ -101,7 +101,13 @@ test('native Core gallery navigation respects mobile bottom safe area and deskto expect(m.transform).not.toBe('none'); } } + // The existing portrait screenshot is a separate evidence scene. Navigate to + // the square image before capture, then return to verify focus restoration. + await dialog.locator('.wp-lightbox-navigation-button-next:visible').click(); + await expect(dialog).toHaveAccessibleName(/Raster square fixture/i); if (evidenceWidths.includes(width(info))) await capture(page, 'lightbox-navigation-' + direction + '-viewport-' + width(info) + '.png'); + await dialog.locator('.wp-lightbox-navigation-button-prev:visible').click(); + await expect(dialog).toHaveAccessibleName(/Raster portrait fixture/i); await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); await expect(trigger).toBeFocused(); diff --git a/tests/test_lightbox_rtl_contract.py b/tests/test_lightbox_rtl_contract.py index 571f45bc..c223ee6f 100644 --- a/tests/test_lightbox_rtl_contract.py +++ b/tests/test_lightbox_rtl_contract.py @@ -32,6 +32,10 @@ def test_gallery_rule_reuses_existing_selector_and_budget(self): def test_keyboard_and_target_size_are_covered(self): self.assertIn('browser-raster-gallery', TEST) self.assertIn('toHaveCount(2)', TEST) + self.assertIn('wp-lightbox-navigation-button-next:visible', TEST) + self.assertIn('wp-lightbox-navigation-button-prev:visible', TEST) + self.assertIn('toHaveAccessibleName(/Raster square fixture/i)', TEST) + self.assertIn('toHaveAccessibleName(/Raster portrait fixture/i)', TEST) self.assertIn('toBeGreaterThanOrEqual(44)', TEST) self.assertIn("page.keyboard.press('Escape')", TEST) self.assertIn('toBeFocused()', TEST) From 0353bc559cdccae4d492127f07aca67343a3c938 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 02:13:39 +0900 Subject: [PATCH 05/33] fix(navigation): reserve bottom safe area in compact menu --- assets/css/navigation.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/css/navigation.css b/assets/css/navigation.css index 9c88c795..336de99d 100644 --- a/assets/css/navigation.css +++ b/assets/css/navigation.css @@ -1,2 +1,2 @@ -.slateframe-primary-nav{min-width:0;max-width:100%}.slateframe-primary-nav ul,.slateframe-menu-fallback{margin:0;padding:0;list-style:none}.slateframe-primary-nav>ul{min-width:0;display:flex;flex-wrap:wrap;gap:var(--slateframe-inline-gap)}.slateframe-primary-nav li{position:relative;min-width:0}.slateframe-primary-nav a,.slateframe-language-slot a{display:flex;align-items:center;min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);padding:var(--slateframe-control-padding-block) var(--slateframe-control-padding-inline);overflow-wrap:anywhere;color:var(--slateframe-text);text-decoration:none}.slateframe-primary-nav .current-menu-item>a,.slateframe-primary-nav .current_page_item>a{text-decoration:underline;text-decoration-thickness:.1em;text-underline-offset:.28em}.slateframe-menu-toggle{display:none}.slateframe-language-slot,.slateframe-language-slot :where(ul,ol),.slateframe-language-slot>:where(div,nav){display:flex;flex-wrap:wrap;align-items:center;gap:var(--slateframe-inline-gap);margin:0;padding:0;list-style:none}.slateframe-language-slot{min-inline-size:0;margin-inline-start:var(--slateframe-inline-gap)}.slateframe-primary-nav :is(.children,.sub-menu){max-inline-size:min(20rem,calc(100vw - 2*var(--slateframe-gutter)))}@media(min-width:1281px){.slateframe-primary-nav:not(.is-compact){display:flex;align-items:center;gap:var(--slateframe-inline-gap);font-size:.875rem;&>ul,& .slateframe-language-slot,& .slateframe-language-slot :where(ul,ol),& .slateframe-language-slot>:where(div,nav){flex-wrap:nowrap}&>ul>li>a,& .slateframe-language-slot a{padding-inline:var(--slateframe-space-2)}& .slateframe-language-slot{flex:0 0 auto}&>ul>li>:is(.children,.sub-menu){position:absolute;inset-block-start:100%;inset-inline-end:0;inline-size:max-content;min-inline-size:12rem;padding:var(--slateframe-space-2);border:var(--slateframe-rule);border-radius:var(--slateframe-radius);background:var(--slateframe-surface);box-shadow:0 .75rem 2rem var(--slateframe-shadow);max-block-size:70dvh;overflow:auto;display:none}& :is(.menu-item-has-children,.page_item_has_children):is(:hover,:focus-within)>:is(.children,.sub-menu){display:block}}}.has-js .slateframe-site-header.is-compact .slateframe-menu-toggle{display:grid;place-items:center}.has-js .slateframe-primary-nav.is-compact{position:absolute;inset-inline:var(--slateframe-gutter);inset-block-start:calc(100% + var(--slateframe-space-2));display:none;padding:var(--slateframe-stack-gap);border:var(--slateframe-rule);border-radius:var(--slateframe-radius);background:var(--slateframe-surface);box-shadow:0 1rem 2.5rem var(--slateframe-shadow);max-block-size:min(70dvh,calc(100dvh - var(--slateframe-header-height) - 2*var(--slateframe-stack-gap)));overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.has-js .slateframe-site-header.is-open .slateframe-primary-nav.is-compact{display:block}.slateframe-primary-nav.is-compact{&>ul,& .slateframe-menu-fallback{display:grid}& .slateframe-language-slot{margin-inline-start:0;padding-block-start:var(--slateframe-component-gap);border-block-start:var(--slateframe-rule)}& :is(.children,.sub-menu){padding-inline:var(--slateframe-stack-gap) 0;border-inline-start:var(--slateframe-rule)}} -@media(max-width:480px){.has-js .slateframe-primary-nav.is-compact{max-block-size:min(55dvh,calc(100dvh - var(--slateframe-header-height) - 2*var(--slateframe-stack-gap)))}} +.slateframe-primary-nav{min-width:0;max-width:100%}.slateframe-primary-nav ul,.slateframe-menu-fallback{margin:0;padding:0;list-style:none}.slateframe-primary-nav>ul{min-width:0;display:flex;flex-wrap:wrap;gap:var(--slateframe-inline-gap)}.slateframe-primary-nav li{position:relative;min-width:0}.slateframe-primary-nav a,.slateframe-language-slot a{display:flex;align-items:center;min-inline-size:var(--slateframe-control);min-block-size:var(--slateframe-control);padding:var(--slateframe-control-padding-block) var(--slateframe-control-padding-inline);overflow-wrap:anywhere;color:var(--slateframe-text);text-decoration:none}.slateframe-primary-nav .current-menu-item>a,.slateframe-primary-nav .current_page_item>a{text-decoration:underline;text-decoration-thickness:.1em;text-underline-offset:.28em}.slateframe-menu-toggle{display:none}.slateframe-language-slot,.slateframe-language-slot :where(ul,ol),.slateframe-language-slot>:where(div,nav){display:flex;flex-wrap:wrap;align-items:center;gap:var(--slateframe-inline-gap);margin:0;padding:0;list-style:none}.slateframe-language-slot{min-inline-size:0;margin-inline-start:var(--slateframe-inline-gap)}.slateframe-primary-nav :is(.children,.sub-menu){max-inline-size:min(20rem,calc(100vw - 2*var(--slateframe-gutter)))}@media(min-width:1281px){.slateframe-primary-nav:not(.is-compact){display:flex;align-items:center;gap:var(--slateframe-inline-gap);font-size:.875rem;&>ul,& .slateframe-language-slot,& .slateframe-language-slot :where(ul,ol),& .slateframe-language-slot>:where(div,nav){flex-wrap:nowrap}&>ul>li>a,& .slateframe-language-slot a{padding-inline:var(--slateframe-space-2)}& .slateframe-language-slot{flex:0 0 auto}&>ul>li>:is(.children,.sub-menu){position:absolute;inset-block-start:100%;inset-inline-end:0;inline-size:max-content;min-inline-size:12rem;padding:var(--slateframe-space-2);border:var(--slateframe-rule);border-radius:var(--slateframe-radius);background:var(--slateframe-surface);box-shadow:0 .75rem 2rem var(--slateframe-shadow);max-block-size:70dvh;overflow:auto;display:none}& :is(.menu-item-has-children,.page_item_has_children):is(:hover,:focus-within)>:is(.children,.sub-menu){display:block}}}.has-js .slateframe-site-header.is-compact .slateframe-menu-toggle{display:grid;place-items:center}.has-js .slateframe-primary-nav.is-compact{position:absolute;inset-inline:var(--slateframe-gutter);inset-block-start:calc(100% + var(--slateframe-space-2));display:none;padding:var(--slateframe-stack-gap);border:var(--slateframe-rule);border-radius:var(--slateframe-radius);background:var(--slateframe-surface);box-shadow:0 1rem 2.5rem var(--slateframe-shadow);max-block-size:min(70dvh,calc(100dvh - var(--slateframe-header-height) - 2*var(--slateframe-stack-gap) - env(safe-area-inset-bottom,0px)));overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.has-js .slateframe-site-header.is-open .slateframe-primary-nav.is-compact{display:block}.slateframe-primary-nav.is-compact{&>ul,& .slateframe-menu-fallback{display:grid}& .slateframe-language-slot{margin-inline-start:0;padding-block-start:var(--slateframe-component-gap);border-block-start:var(--slateframe-rule)}& :is(.children,.sub-menu){padding-inline:var(--slateframe-stack-gap) 0;border-inline-start:var(--slateframe-rule)}} +@media(max-width:480px){.has-js .slateframe-primary-nav.is-compact{max-block-size:min(55dvh,calc(100dvh - var(--slateframe-header-height) - 2*var(--slateframe-stack-gap) - env(safe-area-inset-bottom,0px)))}} From 5831b98bec584eda27232925a89523d185e02b64 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 02:13:50 +0900 Subject: [PATCH 06/33] test(navigation): cover short landscape and RTL menu scrolling --- tests/browser/design-system.spec.js | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/browser/design-system.spec.js b/tests/browser/design-system.spec.js index 12b94542..2712fa03 100644 --- a/tests/browser/design-system.spec.js +++ b/tests/browser/design-system.spec.js @@ -512,3 +512,46 @@ test('Core search, buttons, and Query Pagination inherit the shared control fami expect(disabledState.opacity).toBeLessThan(1); expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1); }); + +test('compact navigation remains scrollable in short landscape and RTL', async ({ page }, testInfo) => { + const width = testInfo.project.use.viewport?.width || 1440; + test.skip(width !== 390, 'Run explicit short-height viewports once rather than multiplying all projects.'); + for (const [viewportWidth, viewportHeight] of [[568, 320], [667, 375], [812, 375]]) { + await page.setViewportSize({ width: viewportWidth, height: viewportHeight }); + for (const direction of ['ltr', 'rtl']) { + await page.goto('/', { waitUntil: 'networkidle' }); + await page.evaluate((dir) => { document.documentElement.dir = dir; }, direction); + const toggle = page.locator('[data-menu-toggle]'); + const nav = page.locator('[data-primary-nav]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + await expect(nav).toBeVisible(); + await nav.locator(':scope > ul').evaluate((list) => { + for (let i = 0; i < 18; i += 1) { + const item = document.createElement('li'); + const anchor = document.createElement('a'); + anchor.href = '#main-content'; + anchor.textContent = 'Translated navigation item ' + i + ' 日本語 العربية'; + item.append(anchor); + list.append(item); + } + }); + const state = await nav.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, viewportHeight: innerHeight, + clientHeight: node.clientHeight, scrollHeight: node.scrollHeight, + overflowY: getComputedStyle(node).overflowY, + rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth }; + }); + expect(state.top).toBeGreaterThanOrEqual(0); + expect(state.bottom, viewportWidth + 'x' + viewportHeight + ' ' + direction).toBeLessThanOrEqual(state.viewportHeight + 1); + expect(state.scrollHeight).toBeGreaterThan(state.clientHeight); + expect(state.overflowY).toBe('auto'); + expect(state.rootOverflow).toBeLessThanOrEqual(1); + const last = nav.locator(':scope > ul > li > a').last(); + await last.focus(); + await expect(last).toBeFocused(); + expect(await nav.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + } + } +}); From b2e88d44e2e089d4558dfd5e779ebbb39ca91a23 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 02:13:57 +0900 Subject: [PATCH 07/33] test(navigation): guard compact safe-area contract --- ...t_compact_navigation_safe_area_contract.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_compact_navigation_safe_area_contract.py diff --git a/tests/test_compact_navigation_safe_area_contract.py b/tests/test_compact_navigation_safe_area_contract.py new file mode 100644 index 00000000..cd1317bd --- /dev/null +++ b/tests/test_compact_navigation_safe_area_contract.py @@ -0,0 +1,26 @@ +"""Guard compact navigation safe-area geometry and short-landscape browser coverage.""" +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class CompactNavigationSafeAreaContract(unittest.TestCase): + def test_both_compact_height_limits_reserve_safe_area(self): + css = (ROOT / 'assets/css/navigation.css').read_text(encoding='utf-8') + pattern = r'max-block-size:min\((?:70|55)dvh,calc\(100dvh - var\(--slateframe-header-height\) - 2\*var\(--slateframe-stack-gap\) - env\(safe-area-inset-bottom,0px\)\)\)' + self.assertEqual(len(re.findall(pattern, css)), 2) + + def test_short_landscape_rtl_coverage(self): + spec = (ROOT / 'tests/browser/design-system.spec.js').read_text(encoding='utf-8') + self.assertIn('compact navigation remains scrollable in short landscape and RTL', spec) + for dims in ('[568, 320]', '[667, 375]', '[812, 375]'): + self.assertIn(dims, spec) + self.assertIn("['ltr', 'rtl']", spec) + self.assertIn('node.scrollTop', spec) + self.assertIn('state.scrollHeight', spec) + + +if __name__ == '__main__': + unittest.main() From 973cb6da2552fcab9f5dbbaa11bf805585048e49 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 02:14:09 +0900 Subject: [PATCH 08/33] docs(navigation): record landscape safe-area behavior --- docs/COMPACT-NAVIGATION-SAFE-AREA.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/COMPACT-NAVIGATION-SAFE-AREA.md diff --git a/docs/COMPACT-NAVIGATION-SAFE-AREA.md b/docs/COMPACT-NAVIGATION-SAFE-AREA.md new file mode 100644 index 00000000..5837bd2f --- /dev/null +++ b/docs/COMPACT-NAVIGATION-SAFE-AREA.md @@ -0,0 +1,11 @@ +# Compact navigation: short landscape and bottom safe areas + +The compact header menu is a scrollable, Core-first presentation surface. On a short landscape viewport, its height cap must reserve the bottom safe area so the final links do not overlap a home indicator. + +Both 70dvh and 55dvh height caps subtract `env(safe-area-inset-bottom,0px)` without changing the shared 44px control token, desktop navigation, or introducing JavaScript. + +The existing design-system Playwright suite now covers 568×320, 667×375, and 812×375 in LTR and RTL, checking viewport containment, scrolling, focus on the last link, and no horizontal overflow. A Python contract prevents the safe-area term or browser scenario from being silently removed. + +Synthetic Chromium verification (not real WordPress): 60 before/after geometry cases, with 0/24/34px modeled bottom insets. The baseline overlapped the safe region in two 568×320 / 34px cases; the corrected formula overlapped in none. + +Release gate: exact-HEAD Theme quality and Browser smoke, followed by actual inspection of WordPress+Chromium screenshots. No production deployment. From 19145a3b396ff8a0bad4924fefc395a221832a3d Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 13:07:16 +0900 Subject: [PATCH 09/33] build: exclude generated Python artifacts from source and release --- .distignore | 7 +++ .gitignore | 6 ++ docs/REPOSITORY-HYGIENE.md | 5 ++ tests/test_repo_hygiene_contract.py | 90 +++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 docs/REPOSITORY-HYGIENE.md create mode 100644 tests/test_repo_hygiene_contract.py diff --git a/.distignore b/.distignore index 7eeb86b1..f42428b2 100644 --- a/.distignore +++ b/.distignore @@ -20,3 +20,10 @@ phpcs.xml.dist # Exclude the packaging manifest itself from published themes. .distignore + +# Defense in depth: generated Python caches never belong in release ZIPs. +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/.gitignore b/.gitignore index 58eb8af4..e2497191 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ dist/ playwright-report/ test-results/ test-artifacts/ +# Generated Python caches must never become theme source. +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/docs/REPOSITORY-HYGIENE.md b/docs/REPOSITORY-HYGIENE.md new file mode 100644 index 00000000..a0ee9af5 --- /dev/null +++ b/docs/REPOSITORY-HYGIENE.md @@ -0,0 +1,5 @@ +# Generated Python artifacts and release hygiene + +Python unit tests may create `__pycache__/` and `.pyc` files during local development. Those are interpreter-specific build products, not Slateframe theme source. Never commit or package them. Git ignores the common Python cache directories and bytecode suffixes; `.distignore` independently excludes them from the WordPress.org archive, including unexpected caches outside `tests/` and `bin/`. + +`tests/test_repo_hygiene_contract.py` is discovered by the existing static-quality Python suite. It checks ignored examples, the Git index and HEAD tree, and an isolated rsync release fixture. It deliberately checks both index and HEAD: ignore patterns do not untrack already committed files. NUL-delimited Git output preserves CJK and unusual path names. Normal Python source remains allowed in development but is already excluded from the distributable via `bin/` and `tests/` rules. diff --git a/tests/test_repo_hygiene_contract.py b/tests/test_repo_hygiene_contract.py new file mode 100644 index 00000000..511933e9 --- /dev/null +++ b/tests/test_repo_hygiene_contract.py @@ -0,0 +1,90 @@ +"""Keep generated Python files out of Git history and WordPress.org ZIPs.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CACHE_DIRS = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +CACHE_SUFFIXES = (".pyc", ".pyo", ".pyd") + + +def is_python_cache(path): + """Recognize generated Python artifacts anywhere in a repository path.""" + parts = Path(path).parts + return any(part in CACHE_DIRS for part in parts) or path.endswith(CACHE_SUFFIXES) + + +def git_paths(*args): + """Read NUL-delimited names so CJK, spaces and newlines stay intact.""" + output = subprocess.check_output(["git", "-C", str(ROOT), *args]) + if args[:2] == ("ls-tree", "-r"): + return [entry.split(b"\t", 1)[1].decode("utf-8", "surrogateescape") + for entry in output.split(b"\0") if entry] + return [entry.decode("utf-8", "surrogateescape") + for entry in output.split(b"\0") if entry] + + +class RepositoryHygieneContract(unittest.TestCase): + def test_git_ignores_python_generated_files(self): + for path in ( + "tests/__pycache__/sample.cpython-313.pyc", + "inc/__pycache__/sample.pyc", + "assets/example.pyo", + "tests/.pytest_cache/v/cache/nodeids", + "tests/.mypy_cache/3.13/meta.json", + "tests/.ruff_cache/index", + ): + with self.subTest(path=path): + result = subprocess.run( + ["git", "-C", str(ROOT), "check-ignore", "--no-index", "-q", "--", path], + check=False, + ) + self.assertEqual(result.returncode, 0, f"Not ignored: {path}") + + def test_no_generated_python_files_are_tracked(self): + for source, names in ( + ("index", git_paths("ls-files", "--cached", "-z")), + ("HEAD", git_paths("ls-tree", "-r", "-z", "HEAD")), + ): + with self.subTest(source=source): + self.assertEqual([name for name in names if is_python_cache(name)], []) + + def test_release_excludes_python_generated_files(self): + if not shutil.which("rsync"): + self.skipTest("rsync is unavailable") + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "src" + target = Path(tmp) / "dist" + for name in ( + "inc/valid.php", "inc/__pycache__/invalid.pyc", "assets/invalid.pyo", + "inc/.pytest_cache/state", "inc/.mypy_cache/state", + "inc/.ruff_cache/state", "assets/valid.css", + ): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fixture", encoding="utf-8") + target.mkdir() + subprocess.run( + ["rsync", "-a", "--exclude-from", str(ROOT / ".distignore"), + str(source) + "/", str(target) + "/"], + check=True, stdout=subprocess.DEVNULL, + ) + self.assertTrue((target / "inc/valid.php").is_file()) + self.assertTrue((target / "assets/valid.css").is_file()) + self.assertEqual([str(p.relative_to(target)) for p in target.rglob("*") + if p.is_file() and is_python_cache(str(p.relative_to(target)))], []) + + def test_ignore_rules_preserve_normal_python_sources(self): + for path in ("bin/check-package-manifest.py", "tests/test_repo_hygiene_contract.py"): + result = subprocess.run( + ["git", "-C", str(ROOT), "check-ignore", "--no-index", "-q", "--", path], + check=False, + ) + self.assertEqual(result.returncode, 1, f"Source unexpectedly ignored: {path}") + + +if __name__ == "__main__": + unittest.main() From 0b56dc68338ffc98b3a70240d982ef11020b671e Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 13:27:42 +0900 Subject: [PATCH 10/33] test(portfolio): wait for lazy images before full-page evidence --- docs/PORTFOLIO-VISUAL-EVIDENCE.md | 7 +++++ tests/browser/theme.spec.js | 16 +++++++++++ tests/test_portfolio_lazy_visual_contract.py | 28 ++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 docs/PORTFOLIO-VISUAL-EVIDENCE.md create mode 100644 tests/test_portfolio_lazy_visual_contract.py diff --git a/docs/PORTFOLIO-VISUAL-EVIDENCE.md b/docs/PORTFOLIO-VISUAL-EVIDENCE.md new file mode 100644 index 00000000..0cb60319 --- /dev/null +++ b/docs/PORTFOLIO-VISUAL-EVIDENCE.md @@ -0,0 +1,7 @@ +# Portfolio full-page screenshot stability + +Core featured images may use native lazy loading. A Playwright `networkidle` navigation does not guarantee that images below the fold have loaded, so a full-page screenshot can capture a gray placeholder in one Actions run and the image in another even when theme runtime files are unchanged. + +The 320px, 200% text-resize Portfolio evidence now scrolls each native Query Loop featured image into view, checks its loaded intrinsic dimensions, waits for image decoding, restores the scroll position, and only then captures the full-page screenshot. This is a test-only evidence gate: it does not change WordPress markup, image loading strategy, theme JS, or performance budgets. Missing/broken fixture images cause the test to fail rather than silently passing with placeholders. + +The prior and current exact-SHA Photography evidence differed in only one 390px image by at most five color-channel values. The larger Portfolio screenshot difference came from an image not yet loaded, not a changed theme stylesheet. Always compare the real screenshots before attributing visual differences to runtime regressions. diff --git a/tests/browser/theme.spec.js b/tests/browser/theme.spec.js index 9488f2c1..b82446bd 100644 --- a/tests/browser/theme.spec.js +++ b/tests/browser/theme.spec.js @@ -369,6 +369,19 @@ test('site identity reflows without truncating multilingual text', async ({ page }); }); +async function settlePortfolioImagesForEvidence(page) { + const images = page.locator('.slateframe-project-grid .wp-block-post-featured-image img'); + const count = await images.count(); + expect(count).toBeGreaterThanOrEqual(4); + for (let index = 0; index < count; index += 1) { + const image = images.nth(index); + await image.scrollIntoViewIfNeeded(); + await expect.poll(() => image.evaluate((node) => node.complete && node.naturalWidth > 0)).toBe(true); + await image.evaluate((node) => node.decode()); + } + await page.evaluate(() => window.scrollTo(0, 0)); +} + test('200% text enlargement keeps core content modes contained', async ({ page }, testInfo) => { test.skip(projectWidth(testInfo) !== 320, 'The narrowest viewport is the strongest text-resize containment fixture.'); const routes = ['/', '/?s=Portable', pagePath, photoPath, projectPath, knowledgePath, showcasePath]; @@ -394,6 +407,9 @@ test('200% text enlargement keeps core content modes contained', async ({ page } node.style.fontSize = '200%'; if (rtl) node.dir = 'rtl'; }, name === 'knowledge-rtl'); + if (name === 'portfolio') { + await settlePortfolioImagesForEvidence(page); + } await page.screenshot({ path: path.join(screenshotDir, `viewport-320-text-resize-${name}.png`), fullPage: true, diff --git a/tests/test_portfolio_lazy_visual_contract.py b/tests/test_portfolio_lazy_visual_contract.py new file mode 100644 index 00000000..ea87ffef --- /dev/null +++ b/tests/test_portfolio_lazy_visual_contract.py @@ -0,0 +1,28 @@ +"""Require decoded Portfolio images before full-page visual evidence capture.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = ROOT / "tests/browser/theme.spec.js" + + +class PortfolioLazyVisualContract(unittest.TestCase): + def test_portfolio_image_decode_gate(self): + source = SPEC.read_text(encoding="utf-8") + self.assertIn("async function settlePortfolioImagesForEvidence(page)", source) + self.assertIn(".slateframe-project-grid .wp-block-post-featured-image img", source) + self.assertIn("node.complete && node.naturalWidth > 0", source) + self.assertIn("await image.evaluate((node) => node.decode())", source) + self.assertIn("await image.scrollIntoViewIfNeeded()", source) + + def test_capture_waits_before_full_page_screenshot(self): + source = SPEC.read_text(encoding="utf-8") + capture = source.index("viewport-320-text-resize-${name}.png") + gate = source.rfind("await settlePortfolioImagesForEvidence(page)", 0, capture) + self.assertGreaterEqual(gate, 0) + self.assertLess(gate, capture) + self.assertIn("if (name === 'portfolio')", source[gate - 90:gate]) + + +if __name__ == "__main__": + unittest.main() From 8637b2491fb87335e708dadccd8ccba52faa9809 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sat, 17 Oct 2026 20:07:16 +0900 Subject: [PATCH 11/33] fix(portfolio): align text-only card styling in Gutenberg --- assets/css/content-modes.css | 2 +- .../test_portfolio_editor_parity_contract.py | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/test_portfolio_editor_parity_contract.py diff --git a/assets/css/content-modes.css b/assets/css/content-modes.css index ef1ee1a8..bdd5a103 100644 --- a/assets/css/content-modes.css +++ b/assets/css/content-modes.css @@ -1,6 +1,6 @@ .wp-block-list.is-style-slateframe-steps,.is-style-slateframe-steps{counter-reset:slateframe-step;padding-inline-start:0;list-style:none}.wp-block-list.is-style-slateframe-steps>li,.is-style-slateframe-steps>li{counter-increment:slateframe-step;display:grid;grid-template-columns:2.15rem minmax(0,1fr);gap:.8rem;align-items:start;margin-block:1rem}.wp-block-list.is-style-slateframe-steps>li::before,.is-style-slateframe-steps>li::before{content:counter(slateframe-step,decimal-leading-zero);display:grid;place-items:center;min-width:2.15rem;min-height:2.15rem;border:var(--slateframe-rule);border-radius:50%;color:var(--slateframe-muted);font-size:.72rem;font-variant-numeric:tabular-nums}.wp-block-list.is-style-slateframe-checklist,.is-style-slateframe-checklist{padding-inline-start:0;list-style:none}.wp-block-list.is-style-slateframe-checklist>li,.is-style-slateframe-checklist>li{position:relative;padding-inline-start:1.65rem;margin-block:.65rem}.wp-block-list.is-style-slateframe-checklist > li::before,.is-style-slateframe-checklist > li::before{position:absolute;inset-inline-start:0;content:"✓";color:var(--slateframe-accent);font-weight:800;}.wp-block-group.is-style-slateframe-key-facts{padding-block:var(--slateframe-stack-gap);border-block:var(--slateframe-rule);}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :first-child{margin-block-start:0;}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :last-child{margin-block-end:0;}.wp-block-group.is-style-slateframe-learning-callout{padding:var(--slateframe-stack-gap);border-inline-start:3px solid var(--slateframe-accent);background:var(--slateframe-surface-soft);}.wp-block-columns.is-style-slateframe-ledger{gap:clamp(1rem,2.5vw,2rem);margin-block:0;padding-block:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-ledger:last-child{border-block-end:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief{padding-block:clamp(1.25rem,3vw,2rem);border-block:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief a{overflow-wrap:anywhere;}.slateframe-portfolio-index .wp-block-columns,.slateframe-knowledge-outline .wp-block-columns,.slateframe-project-brief-pattern .wp-block-group,.slateframe-lesson-chapter .wp-block-group{margin-block-start:clamp(1.5rem,3vw,2.25rem);}.slateframe-portfolio-index h3,.slateframe-knowledge-outline h3,.slateframe-project-brief-pattern h3,.slateframe-lesson-chapter h3{margin-block-start:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-ledger{padding-block:0.9rem;}}.wp-block-columns.is-style-slateframe-metrics{gap:0;border-block:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column{min-inline-size:0;padding:var(--slateframe-stack-gap);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column:first-child{border-inline-start:0;}.wp-block-group.is-style-slateframe-definition{padding:1rem 1.1rem;border-inline-start:3px solid var(--slateframe-text);background:var(--slateframe-surface-soft);}.wp-block-group.is-style-slateframe-definition > :first-child{margin-block-start:0;}.wp-block-group.is-style-slateframe-definition > :last-child{margin-block-end:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}}.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:clamp(1.75rem,4vw,3.25rem);border-block:var(--slateframe-rule);}.slateframe-project-meta{display:grid;gap:var(--slateframe-component-gap);margin:0;padding:0;list-style:none;}.slateframe-project-meta li{padding-block-end:var(--slateframe-caption-gap);border-block-end:var(--slateframe-rule);}.slateframe-project-meta strong{display:block;margin-block-end:0.12rem;font-size:0.76rem;letter-spacing:0.04em;text-transform:uppercase;}.slateframe-project-sections{margin-block-start:clamp(1.75rem,4vw,3rem);}.slateframe-project-sections h3,.slateframe-learning-step h3{margin-block-start:0;}.slateframe-learning-step{min-height:100%;padding-block-start:var(--slateframe-component-gap);border-block-start:2px solid var(--slateframe-text);}.slateframe-learning-step > :last-child{margin-block-end:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint{gap:0;margin-block:var(--slateframe-heading-gap);border-block:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column{min-inline-size:0;padding:clamp(1rem,2.5vw,1.5rem);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column:first-child{border-inline-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint h3{margin-block-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint .is-style-slateframe-checklist{margin-block-end:0;}.slateframe-knowledge-checklist > .wp-block-quote{margin-block-start:clamp(1.5rem,3vw,2.25rem);}@media (max-width:781px){.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:1.5rem;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}} /* No synthetic media: balance Core Portfolio cards without featured images. */ -.slateframe-main .slateframe-project-grid .slateframe-project-card:not(:has(.wp-block-post-featured-image img)){padding-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-media-gap));padding-inline-start:var(--slateframe-stack-gap);border-inline-start:2px solid var(--slateframe-accent)} +:is(.slateframe-main,.editor-styles-wrapper) .slateframe-project-grid .slateframe-project-card:not(:has(.wp-block-post-featured-image img)){padding-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-media-gap));padding-inline-start:var(--slateframe-stack-gap);border-inline-start:2px solid var(--slateframe-accent)} /* Native Query Loop titles share the Appearance control target; media focus remains visible inside cropped frames. */ .slateframe-project-grid .wp-block-post-title a{display:block;min-block-size:var(--slateframe-control)}.slateframe-project-grid .wp-block-post-featured-image a{display:block;line-height:0}.slateframe-project-grid .wp-block-post-featured-image a:focus-visible{outline-offset:calc(-1*var(--slateframe-focus-offset))} diff --git a/tests/test_portfolio_editor_parity_contract.py b/tests/test_portfolio_editor_parity_contract.py new file mode 100644 index 00000000..0e3b15c2 --- /dev/null +++ b/tests/test_portfolio_editor_parity_contract.py @@ -0,0 +1,31 @@ +"""Ensure native Portfolio cards have matching frontend and Gutenberg treatment.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CSS = ROOT / "assets/css/content-modes.css" +FUNCTIONS = ROOT / "functions.php" + +class PortfolioEditorParityContract(unittest.TestCase): + def test_frontend_and_editor_share_text_only_rule(self): + css = CSS.read_text(encoding="utf-8") + self.assertIn(":is(.slateframe-main,.editor-styles-wrapper) .slateframe-project-grid .slateframe-project-card:not(:has(.wp-block-post-featured-image img))", css) + self.assertNotIn(".slateframe-main .slateframe-project-grid .slateframe-project-card:not(", css) + + def test_image_presence_controls_decoration(self): + css = CSS.read_text(encoding="utf-8") + self.assertIn(":not(:has(.wp-block-post-featured-image img))", css) + self.assertIn("border-inline-start:2px solid var(--slateframe-accent)", css) + self.assertIn("padding-inline-start:var(--slateframe-stack-gap)", css) + + def test_editor_uses_the_same_stylesheet(self): + functions = FUNCTIONS.read_text(encoding="utf-8") + self.assertIn("add_theme_support( 'editor-styles' )", functions) + self.assertIn("'assets/css/content-modes.css'", functions) + self.assertIn("add_editor_style(", functions) + + def test_existing_budget_is_unchanged(self): + self.assertLessEqual(CSS.stat().st_size, 7000) + +if __name__ == "__main__": + unittest.main() From dfe29952ff2bff12ab4149e62a5b02813b334bb1 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 00:10:20 +0900 Subject: [PATCH 12/33] test(visual): decode lazy portfolio and photography images before evidence --- .github/workflows/quality.yml | 2 + tests/browser/appearance.spec.js | 5 +- tests/browser/test-visual-evidence-helper.cjs | 64 +++++++++++++++++++ tests/browser/theme.spec.js | 14 +--- tests/browser/visual-evidence.js | 39 +++++++++++ 5 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 tests/browser/test-visual-evidence-helper.cjs create mode 100644 tests/browser/visual-evidence.js diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d923de0c..80dc7bd5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -36,6 +36,8 @@ jobs: node --check assets/js/theme.js node --check playwright.config.js find tests/browser -type f -name '*.js' -print0 | xargs -0 -n1 node --check + node --check tests/browser/test-visual-evidence-helper.cjs + node tests/browser/test-visual-evidence-helper.cjs - name: Validate build script syntax run: bash -n bin/build-theme-zip.sh - name: Validate screenshot PNG and corruption regression diff --git a/tests/browser/appearance.spec.js b/tests/browser/appearance.spec.js index 8c7bb2b1..376da0d1 100644 --- a/tests/browser/appearance.spec.js +++ b/tests/browser/appearance.spec.js @@ -1,4 +1,5 @@ const { test, expect } = require('@playwright/test'); +const { settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence } = require('./visual-evidence'); const fs = require('node:fs/promises'); const path = require('node:path'); const pagePath = process.env.SLATEFRAME_PAGE_PATH || '/'; @@ -19,7 +20,7 @@ const near = (received, expected, tolerance = 1) => expect(Math.abs(received - e */ async function expectLightboxSettled(dialog) { const image = dialog.locator('img[sizes="100vw"]').last(); - await expect(image).toBeVisible(); + await settleLightboxImageForEvidence(image); await expect.poll(() => dialog.evaluate((node) => node.getAnimations({ subtree: true }).filter((animation) => animation.playState === 'running').length ), { message: 'native lightbox animations must finish before visual evidence', timeout: 5_000 }).toBe(0); @@ -447,6 +448,8 @@ test('appearance profile stays contained across content modes and captures revie expect(['contain', 'fill']).toContain(image.objectFit); } } + if (name === 'portfolio') await settlePortfolioImagesForEvidence(page); + if (name === 'photography') await settlePhotographyImagesForEvidence(page); await page.screenshot({ path: path.join(screenshotDir, `appearance-${profileName}-${testInfo.project.name}-${name}.png`), fullPage: true }); } }); diff --git a/tests/browser/test-visual-evidence-helper.cjs b/tests/browser/test-visual-evidence-helper.cjs new file mode 100644 index 00000000..bc9bb262 --- /dev/null +++ b/tests/browser/test-visual-evidence-helper.cjs @@ -0,0 +1,64 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); + +const expect = (value) => ({ + toBeGreaterThanOrEqual: (minimum) => assert.ok(value >= minimum), + toBeVisible: async () => assert.notEqual(value.visible, false), +}); +expect.poll = (getter) => ({ + toBe: async (target) => assert.equal(await getter(), target), + toBeLessThanOrEqual: async (maximum) => assert.ok((await getter()) <= maximum), +}); +const context = { module: { exports: {} }, require: (id) => { + assert.equal(id, '@playwright/test'); + return { expect }; +} }; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'visual-evidence.js'), 'utf8'), context); +const { settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence } = context.module.exports; + +function fixture(count, options = {}) { + const log = []; + const nodes = Array.from({ length: count }, (_, i) => ({ + complete: true, + naturalWidth: options.brokenAt === i ? 0 : 160, + naturalHeight: options.zeroHeightAt === i ? 0 : 120, + decode: async () => { log.push('decode'); if (options.decodeFailsAt === i) throw Error('decode failed'); }, + })); + const page = { + locator: () => ({ + count: async () => nodes.length, + nth: (i) => ({ + visible: options.hiddenAt !== i, + scrollIntoViewIfNeeded: async () => log.push('scroll'), + evaluate: async (fn) => fn(nodes[i]), + }), + }), + evaluate: async (fn) => { + log.push('reset'); + return String(fn).includes('Math.max') ? (options.scrollOffset || 0) : 0; + }, + }; + return { page, log }; +} + +(async () => { + const portfolio = fixture(4); + await settlePortfolioImagesForEvidence(portfolio.page); + assert.equal(portfolio.log.filter((x) => x === 'decode').length, 4); + const photography = fixture(5); + await settlePhotographyImagesForEvidence(photography.page); + assert.equal(photography.log.filter((x) => x === 'decode').length, 5); + await assert.rejects(() => settlePortfolioImagesForEvidence(fixture(3).page)); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(4).page)); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(5, { brokenAt: 2 }).page)); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(5, { zeroHeightAt: 2 }).page)); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(5, { hiddenAt: 2 }).page)); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(5, { decodeFailsAt: 2 }).page), /decode failed/); + await assert.rejects(() => settlePhotographyImagesForEvidence(fixture(5, { scrollOffset: 8 }).page)); + await settleLightboxImageForEvidence(fixture(1).page.locator().nth(0)); + await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { brokenAt: 0 }).page.locator().nth(0))); + await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { decodeFailsAt: 0 }).page.locator().nth(0))); + console.log('12/12 visual evidence helper scenarios passed'); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/browser/theme.spec.js b/tests/browser/theme.spec.js index b82446bd..5f72aa1d 100644 --- a/tests/browser/theme.spec.js +++ b/tests/browser/theme.spec.js @@ -1,3 +1,4 @@ +const { settlePortfolioImagesForEvidence } = require('./visual-evidence'); const fs = require('node:fs/promises'); const path = require('node:path'); const { test, expect } = require('@playwright/test'); @@ -369,19 +370,6 @@ test('site identity reflows without truncating multilingual text', async ({ page }); }); -async function settlePortfolioImagesForEvidence(page) { - const images = page.locator('.slateframe-project-grid .wp-block-post-featured-image img'); - const count = await images.count(); - expect(count).toBeGreaterThanOrEqual(4); - for (let index = 0; index < count; index += 1) { - const image = images.nth(index); - await image.scrollIntoViewIfNeeded(); - await expect.poll(() => image.evaluate((node) => node.complete && node.naturalWidth > 0)).toBe(true); - await image.evaluate((node) => node.decode()); - } - await page.evaluate(() => window.scrollTo(0, 0)); -} - test('200% text enlargement keeps core content modes contained', async ({ page }, testInfo) => { test.skip(projectWidth(testInfo) !== 320, 'The narrowest viewport is the strongest text-resize containment fixture.'); const routes = ['/', '/?s=Portable', pagePath, photoPath, projectPath, knowledgePath, showcasePath]; diff --git a/tests/browser/visual-evidence.js b/tests/browser/visual-evidence.js new file mode 100644 index 00000000..6bf15bdc --- /dev/null +++ b/tests/browser/visual-evidence.js @@ -0,0 +1,39 @@ +const { expect } = require('@playwright/test'); + +/** Settle every lazy Core image before capturing full-page visual evidence. */ +async function settleImagesForEvidence(page, selector, minimumCount) { + const images = page.locator(selector); + const count = await images.count(); + expect(count).toBeGreaterThanOrEqual(minimumCount); + for (let index = 0; index < count; index += 1) { + const image = images.nth(index); + await image.scrollIntoViewIfNeeded(); + await expect(image).toBeVisible(); + await expect.poll(() => image.evaluate((node) => + node.complete && node.naturalWidth > 0 && node.naturalHeight > 0 + )).toBe(true); + await image.evaluate((node) => node.decode()); + } + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + await expect.poll(() => page.evaluate(() => + Math.max(Math.abs(window.scrollX), Math.abs(window.scrollY)) + )).toBeLessThanOrEqual(1); +} + +async function settlePortfolioImagesForEvidence(page) { + await settleImagesForEvidence(page, '.slateframe-project-grid .wp-block-post-featured-image img', 4); +} + +async function settlePhotographyImagesForEvidence(page) { + await settleImagesForEvidence(page, '#main-content .wp-block-image img', 5); +} + +async function settleLightboxImageForEvidence(image) { + await expect(image).toBeVisible(); + await expect.poll(() => image.evaluate((node) => + node.complete && node.naturalWidth > 0 && node.naturalHeight > 0 + )).toBe(true); + await image.evaluate((node) => node.decode()); +} + +module.exports = { settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence }; From 767a1a18a1d9026cae9feed73ab8463a044c5684 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 00:12:36 +0900 Subject: [PATCH 13/33] test(visual): follow shared lazy-image helper without weakening evidence gates --- tests/test_portfolio_lazy_visual_contract.py | 53 +++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/tests/test_portfolio_lazy_visual_contract.py b/tests/test_portfolio_lazy_visual_contract.py index ea87ffef..4ce29d36 100644 --- a/tests/test_portfolio_lazy_visual_contract.py +++ b/tests/test_portfolio_lazy_visual_contract.py @@ -1,28 +1,57 @@ -"""Require decoded Portfolio images before full-page visual evidence capture.""" +"""Require decoded Core images before every full-page visual evidence capture.""" from pathlib import Path import unittest ROOT = Path(__file__).resolve().parents[1] -SPEC = ROOT / "tests/browser/theme.spec.js" +SPEC = ROOT / 'tests/browser/theme.spec.js' +APPEARANCE = ROOT / 'tests/browser/appearance.spec.js' +HELPER = ROOT / 'tests/browser/visual-evidence.js' class PortfolioLazyVisualContract(unittest.TestCase): def test_portfolio_image_decode_gate(self): - source = SPEC.read_text(encoding="utf-8") - self.assertIn("async function settlePortfolioImagesForEvidence(page)", source) - self.assertIn(".slateframe-project-grid .wp-block-post-featured-image img", source) - self.assertIn("node.complete && node.naturalWidth > 0", source) - self.assertIn("await image.evaluate((node) => node.decode())", source) - self.assertIn("await image.scrollIntoViewIfNeeded()", source) + source = HELPER.read_text(encoding='utf-8') + self.assertIn('async function settlePortfolioImagesForEvidence(page)', source) + self.assertIn('.slateframe-project-grid .wp-block-post-featured-image img', source) + self.assertIn('node.complete && node.naturalWidth > 0 && node.naturalHeight > 0', source) + self.assertIn('await image.evaluate((node) => node.decode())', source) + self.assertIn('await image.scrollIntoViewIfNeeded()', source) + self.assertIn('await expect(image).toBeVisible()', source) + self.assertIn("behavior: 'instant'", source) def test_capture_waits_before_full_page_screenshot(self): - source = SPEC.read_text(encoding="utf-8") - capture = source.index("viewport-320-text-resize-${name}.png") - gate = source.rfind("await settlePortfolioImagesForEvidence(page)", 0, capture) + source = SPEC.read_text(encoding='utf-8') + self.assertIn("require('./visual-evidence')", source) + capture = source.index('viewport-320-text-resize-${name}.png') + gate = source.rfind('await settlePortfolioImagesForEvidence(page)', 0, capture) self.assertGreaterEqual(gate, 0) self.assertLess(gate, capture) self.assertIn("if (name === 'portfolio')", source[gate - 90:gate]) + def test_appearance_portfolio_waits_before_full_page_screenshot(self): + source = APPEARANCE.read_text(encoding='utf-8') + capture = source.index('appearance-${profileName}-${testInfo.project.name}-${name}.png') + gate = source.rfind('await settlePortfolioImagesForEvidence(page)', 0, capture) + self.assertGreaterEqual(gate, 0) + self.assertLess(gate, capture) + self.assertIn("if (name === 'portfolio')", source[gate - 90:gate]) + + def test_appearance_photography_waits_for_core_images(self): + source = APPEARANCE.read_text(encoding='utf-8') + helper = HELPER.read_text(encoding='utf-8') + capture = source.index('appearance-${profileName}-${testInfo.project.name}-${name}.png') + gate = source.rfind('await settlePhotographyImagesForEvidence(page)', 0, capture) + self.assertGreaterEqual(gate, 0) + self.assertIn("if (name === 'photography')", source[gate - 90:gate]) + self.assertIn("'#main-content .wp-block-image img', 5", helper) + + def test_lightbox_image_is_decoded_before_geometry_acceptance(self): + source = APPEARANCE.read_text(encoding='utf-8') + self.assertIn('await settleLightboxImageForEvidence(image);', source) + gate = source.index('await settleLightboxImageForEvidence(image);') + geometry = source.index('native lightbox animations must finish before visual evidence') + self.assertLess(gate, geometry) + -if __name__ == "__main__": +if __name__ == '__main__': unittest.main() From c840676f8cac2bb3e1d2d98b13ebb4dbcb531d63 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 00:36:37 +0900 Subject: [PATCH 14/33] fix(portfolio): respect native Query Loop columns and Core grid layout --- assets/css/query-loop.css | 4 +- docs/PORTFOLIO-CORE-COLUMNS.md | 7 +++ tests/browser/portfolio-core-columns.spec.js | 64 ++++++++++++++++++++ tests/test_portfolio_columns_contract.py | 35 +++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 docs/PORTFOLIO-CORE-COLUMNS.md create mode 100644 tests/browser/portfolio-core-columns.spec.js create mode 100644 tests/test_portfolio_columns_contract.py diff --git a/assets/css/query-loop.css b/assets/css/query-loop.css index 11bf44c2..5f8a6786 100644 --- a/assets/css/query-loop.css +++ b/assets/css/query-loop.css @@ -1,2 +1,2 @@ -.slateframe-project-grid .wp-block-query.alignwide{inline-size:100%;max-inline-size:var(--wp--style--global--wide-size);}.slateframe-project-grid .wp-block-post-template > .wp-block-post{inline-size:auto !important;max-inline-size:none !important;min-inline-size:0;flex-basis:auto !important;}.slateframe-project-grid .wp-block-post-template{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));inline-size:100%;max-inline-size:none;margin-inline:0;padding-inline:0;gap:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}.slateframe-project-grid .slateframe-project-card{display:flex;flex-direction:column;min-inline-size:0;height:100%;padding-block-start:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule)}.slateframe-project-grid .wp-block-post-featured-image{margin:0 0 var(--slateframe-media-gap);overflow:hidden;background:var(--slateframe-surface-soft)}.slateframe-project-grid .wp-block-post-featured-image img{width:100%;transition:transform 180ms ease}.slateframe-project-grid .wp-block-post-title{margin-block:0;line-height:1.18;overflow-wrap:anywhere;text-wrap:balance}.slateframe-project-grid .wp-block-post-title a{color:var(--slateframe-text);text-decoration:none}.slateframe-project-grid :is(.wp-block-post-excerpt,.wp-block-post-date){color:var(--slateframe-muted)}.slateframe-project-grid .wp-block-post-excerpt{margin-block:var(--slateframe-component-gap) 0}.slateframe-project-grid .wp-block-post-excerpt p{margin:0}.slateframe-project-grid .wp-block-post-date{margin-block-start:auto;padding-block-start:var(--slateframe-space-3);font-size:.78rem;font-variant-numeric:tabular-nums}.slateframe-project-grid .wp-block-query-pagination{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--slateframe-inline-gap) var(--slateframe-component-gap);margin-block-start:var(--slateframe-heading-gap);padding-block-start:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule)}.slateframe-project-grid .wp-block-query-pagination-numbers{display:flex;flex-wrap:wrap;gap:var(--slateframe-inline-gap)}.slateframe-project-grid .wp-block-query-pagination :is(a,.page-numbers){overflow-wrap:anywhere}.slateframe-project-grid .wp-block-query-pagination .current{background:var(--slateframe-text);color:var(--slateframe-bg)}@media(hover:hover){.slateframe-project-grid .wp-block-post-featured-image a:hover img{transform:scale(1.015)}}@media(prefers-reduced-motion:reduce){.slateframe-project-grid .wp-block-post-featured-image img{transition-duration:.01ms}.slateframe-project-grid .wp-block-post-featured-image a:hover img{transform:none}}@media(max-width:900px){.slateframe-project-grid .wp-block-post-template{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.slateframe-project-grid .wp-block-post-template{grid-template-columns:minmax(0,1fr)}} -.slateframe-footer-content .wp-block-post-template{grid-template-columns:1fr} \ No newline at end of file +.slateframe-project-grid{& .wp-block-query.alignwide{inline-size:100%;max-inline-size:var(--wp--style--global--wide-size);}& .wp-block-post-template > .wp-block-post{inline-size:auto !important;max-inline-size:none !important;min-inline-size:0;flex-basis:auto !important;}& .wp-block-post-template:where(:not(.is-layout-grid)){--slateframe-project-columns:3;display:grid!important;grid-template-columns:repeat(var(--slateframe-project-columns),minmax(0,1fr));inline-size:100%;max-inline-size:none;margin-inline:0;padding-inline:0;gap:calc(var(--slateframe-stack-gap) + var(--slateframe-component-gap))}& .wp-block-post-template{&.columns-1{--slateframe-project-columns:1}&.columns-2{--slateframe-project-columns:2}&.columns-3{--slateframe-project-columns:3}&.columns-4{--slateframe-project-columns:4}&.columns-5{--slateframe-project-columns:5}&.columns-6{--slateframe-project-columns:6}}& .slateframe-project-card{display:flex;flex-direction:column;min-inline-size:0;height:100%;padding-block-start:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule)}& .wp-block-post-featured-image{margin:0 0 var(--slateframe-media-gap);overflow:hidden;background:var(--slateframe-surface-soft)}& .wp-block-post-featured-image img{width:100%;transition:transform 180ms ease}& .wp-block-post-title{margin-block:0;line-height:1.18;overflow-wrap:anywhere;text-wrap:balance}& .wp-block-post-title a{color:var(--slateframe-text);text-decoration:none}& :is(.wp-block-post-excerpt,.wp-block-post-date){color:var(--slateframe-muted)}& .wp-block-post-excerpt{margin-block:var(--slateframe-component-gap) 0}& .wp-block-post-excerpt p{margin:0}& .wp-block-post-date{margin-block-start:auto;padding-block-start:var(--slateframe-space-3);font-size:.78rem;font-variant-numeric:tabular-nums}& .wp-block-query-pagination{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--slateframe-inline-gap) var(--slateframe-component-gap);margin-block-start:var(--slateframe-heading-gap);padding-block-start:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule)}& .wp-block-query-pagination-numbers{display:flex;flex-wrap:wrap;gap:var(--slateframe-inline-gap)}& .wp-block-query-pagination :is(a,.page-numbers){overflow-wrap:anywhere}& .wp-block-query-pagination .current{background:var(--slateframe-text);color:var(--slateframe-bg)}@media(hover:hover){& .wp-block-post-featured-image a:hover img{transform:scale(1.015)}}@media(prefers-reduced-motion:reduce){& .wp-block-post-featured-image img{transition-duration:.01ms}& .wp-block-post-featured-image a:hover img{transform:none}}@media(max-width:900px){& .wp-block-post-template:where(:not(.is-layout-grid)){grid-template-columns:repeat(min(2,var(--slateframe-project-columns)),minmax(0,1fr))}}@media(max-width:640px){& .wp-block-post-template:where(:not(.is-layout-grid)){grid-template-columns:minmax(0,1fr)}}} +.slateframe-footer-content .wp-block-post-template{grid-template-columns:1fr} diff --git a/docs/PORTFOLIO-CORE-COLUMNS.md b/docs/PORTFOLIO-CORE-COLUMNS.md new file mode 100644 index 00000000..541efe16 --- /dev/null +++ b/docs/PORTFOLIO-CORE-COLUMNS.md @@ -0,0 +1,7 @@ +# Core Query Loop column parity + +The Portfolio pattern is a native WordPress Query Loop. Slateframe must respect the author's Core `columns-1` through `columns-6` choice instead of forcing three desktop columns. On tablet widths (641–900px) the layout caps at two columns without turning a deliberate single-column layout into two. At 640px and below it uses one column. + +When Core chooses `is-layout-grid`, the Core grid layout remains authoritative; Slateframe only overrides the legacy flex template. The footer retains its own single-column rule. All controls, typography, links, dates and featured-image behavior remain unchanged. + +Validation: `python3 -m unittest tests/test_portfolio_columns_contract.py`; `tests/browser/portfolio-core-columns.spec.js` runs against the real WordPress Portfolio fixture in Theme quality. Inspect exact-SHA 390/1440px screenshots and Core editor parity before merge. Earlier isolated Chromium verification exercised 360 combinations of 320–1920px, 1–6 columns, RTL/LTR, editor/front-end and Core Grid, but it does not replace WordPress Actions. diff --git a/tests/browser/portfolio-core-columns.spec.js b/tests/browser/portfolio-core-columns.spec.js new file mode 100644 index 00000000..faa89146 --- /dev/null +++ b/tests/browser/portfolio-core-columns.spec.js @@ -0,0 +1,64 @@ +const { test, expect } = require('@playwright/test'); +const projectPath = process.env.SLATEFRAME_PROJECT_PATH; +const width = (info) => info.project.use.viewport?.width || 0; +const columns = (chosen, w) => w <= 640 ? 1 : w <= 900 ? Math.min(2, chosen) : chosen; + +async function openTemplate(page) { + await page.goto(projectPath, { waitUntil: 'domcontentloaded' }); + const list = page.locator('.slateframe-project-grid .wp-block-post-template').first(); + await expect(list).toBeVisible(); + await expect(list.locator(':scope > .wp-block-post').first()).toBeVisible(); + return list; +} +async function metrics(list) { + return list.evaluate((node) => ({ + display: getComputedStyle(node).display, + tracks: getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); +} + +test('Core Query Loop respects columns 1–6 and mobile/tablet caps', async ({ page }, info) => { + test.skip(!projectPath, 'Requires the real WordPress Portfolio fixture'); + const list = await openTemplate(page); + for (const chosen of [1, 2, 3, 4, 5, 6]) { + await list.evaluate((node, count) => { + for (const name of [...node.classList]) if (/^columns-[1-6]$/.test(name)) node.classList.remove(name); + node.classList.add('columns-' + count); + }, chosen); + const actual = await metrics(list); + expect(actual.display).toBe('grid'); + expect(actual.tracks, 'viewport ' + width(info) + ', columns ' + chosen).toBe(columns(chosen, width(info))); + expect(actual.overflow).toBeLessThanOrEqual(1); + } +}); + +test('Core is-layout-grid remains authoritative', async ({ page }) => { + test.skip(!projectPath, 'Requires the real WordPress Portfolio fixture'); + const list = await openTemplate(page); + await list.evaluate((node) => { + node.classList.remove('is-flex-container'); + node.classList.add('is-layout-grid'); + node.style.display = 'grid'; + node.style.gridTemplateColumns = 'repeat(2,minmax(0,1fr))'; + }); + const actual = await metrics(list); + expect(actual.tracks).toBe(2); + expect(actual.overflow).toBeLessThanOrEqual(1); +}); + +test('RTL/CJK and 200% text enlargement preserve grid boundaries', async ({ page }, info) => { + test.skip(!projectPath, 'Requires the real WordPress Portfolio fixture'); + const list = await openTemplate(page); + await page.locator('html').evaluate((node) => { node.dir = 'rtl'; node.style.fontSize = '200%'; }); + await list.evaluate((node) => { + for (const name of [...node.classList]) if (/^columns-[1-6]$/.test(name)) node.classList.remove(name); + node.classList.add('columns-6'); + node.querySelectorAll('.wp-block-post-title a').forEach((a) => { + a.textContent = '日本語 العربية ' + 'unbroken-project-identifier-'.repeat(4); + }); + }); + const actual = await metrics(list); + expect(actual.tracks).toBe(columns(6, width(info))); + expect(actual.overflow).toBeLessThanOrEqual(1); +}); diff --git a/tests/test_portfolio_columns_contract.py b/tests/test_portfolio_columns_contract.py new file mode 100644 index 00000000..f43495d7 --- /dev/null +++ b/tests/test_portfolio_columns_contract.py @@ -0,0 +1,35 @@ +"""Protect native Query Loop column choices without expanding CSS budgets.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class PortfolioColumnsContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.css = (ROOT / 'assets/css/query-loop.css').read_text(encoding='utf-8') + + def test_budget_is_unchanged(self): + self.assertLessEqual(len(self.css.encode('utf-8')), 3000) + + def test_native_flex_column_choices_are_supported(self): + for count in range(1, 7): + self.assertIn(f'&.columns-{count}{{--slateframe-project-columns:{count}}}', self.css) + self.assertIn('repeat(var(--slateframe-project-columns),minmax(0,1fr))', self.css) + + def test_native_grid_layout_is_not_overridden(self): + self.assertIn('.wp-block-post-template:where(:not(.is-layout-grid))', self.css) + self.assertNotIn('.wp-block-post-template{display:grid!important;', self.css) + + def test_tablet_and_mobile_caps_preserve_single_column_choices(self): + self.assertIn('repeat(min(2,var(--slateframe-project-columns)),minmax(0,1fr))', self.css) + self.assertIn('@media(max-width:640px)', self.css) + + def test_footer_query_loop_remains_one_column(self): + self.assertIn('.slateframe-footer-content .wp-block-post-template{grid-template-columns:1fr}', self.css) + self.assertIn(':where(:not(.is-layout-grid))', self.css) + + +if __name__ == '__main__': + unittest.main() From 604d7025ebf989314db44d6777c2839e3612faa5 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 00:39:01 +0900 Subject: [PATCH 15/33] test(portfolio): align footer contract with Core Query Loop column choices --- tests/test_footer_shared_sources.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_footer_shared_sources.py b/tests/test_footer_shared_sources.py index a3f6c3c8..0c163e93 100644 --- a/tests/test_footer_shared_sources.py +++ b/tests/test_footer_shared_sources.py @@ -90,7 +90,10 @@ def test_unpublished_synced_pattern_not_loaded_by_either_detector(self): def test_footer_grid_is_one_column_without_changing_main_grid(self): css = (ROOT / 'assets/css/query-loop.css').read_text() self.assertIn('.slateframe-footer-content .wp-block-post-template{grid-template-columns:1fr}', css) - self.assertIn('.slateframe-project-grid .wp-block-post-template{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))', css) + self.assertIn('.wp-block-post-template:where(:not(.is-layout-grid)){--slateframe-project-columns:3;display:grid!important;grid-template-columns:repeat(var(--slateframe-project-columns),minmax(0,1fr))', css) + for count in range(1, 7): + self.assertIn(f'&.columns-{count}{{--slateframe-project-columns:{count}}}', css) + self.assertIn('repeat(min(2,var(--slateframe-project-columns)),minmax(0,1fr))', css) self.assertIn(':is(.wp-block-post-excerpt,.wp-block-post-date){color:var(--slateframe-muted)}', css) self.assertLessEqual(len(css.encode('utf-8')), 3000) From fac5874db3192ee8dd1ea519c686d44594de1f4d Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 00:55:27 +0900 Subject: [PATCH 16/33] test(ci): isolate authenticated Gutenberg browser specs from parallel workers --- .github/workflows/quality.yml | 9 ++++- docs/GUTENBERG-LOGIN-CI-ISOLATION.md | 7 ++++ tests/test_editor_login_isolation_contract.py | 36 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 docs/GUTENBERG-LOGIN-CI-ISOLATION.md create mode 100644 tests/test_editor_login_isolation_contract.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 80dc7bd5..5b242bb4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -823,18 +823,25 @@ jobs: # must not overlap each other or read-only stylesheet assertions. readonly_specs=() stateful_specs=() + authenticated_specs=() for spec in tests/browser/*.spec.js; do if grep -Eq 'execFileSync|execSync|spawnSync' "$spec"; then stateful_specs+=("$spec") + elif grep -Eq 'openAuthenticatedEditor|support/editor-login' "$spec"; then + # Login POSTs mutate WordPress session state; isolate them from + # parallel readers and fixture writers without skipping assertions. + authenticated_specs+=("$spec") else readonly_specs+=("$spec") fi done test "${#readonly_specs[@]}" -gt 0 test "${#stateful_specs[@]}" -gt 0 - printf 'Parallel read-only specs: %s; isolated WP fixture specs: %s\\n' "${#readonly_specs[@]}" "${#stateful_specs[@]}" + test "${#authenticated_specs[@]}" -gt 0 + printf 'Parallel read-only: %s; isolated WP fixture: %s; isolated Gutenberg login: %s\\n' "${#readonly_specs[@]}" "${#stateful_specs[@]}" "${#authenticated_specs[@]}" npx playwright test "${readonly_specs[@]}" npx playwright test "${stateful_specs[@]}" --workers=1 + npx playwright test "${authenticated_specs[@]}" --workers=1 - name: Verify compact Appearance bounds if: ${{ always() }} env: diff --git a/docs/GUTENBERG-LOGIN-CI-ISOLATION.md b/docs/GUTENBERG-LOGIN-CI-ISOLATION.md new file mode 100644 index 00000000..0a893817 --- /dev/null +++ b/docs/GUTENBERG-LOGIN-CI-ISOLATION.md @@ -0,0 +1,7 @@ +# Gutenberg login isolation in Browser smoke + +Real WordPress Gutenberg acceptance tests perform login POSTs and create session cookies. They are not read-only browser assertions. Previously the Browser smoke workflow grouped specs only by WP-CLI subprocess usage, so `cjk-heading-editor.spec.js` and `editor.spec.js` ran among parallel read-only tests. + +Intermittent `page.waitForResponse` timeouts occurred at the same login helper on 1440px (run 39027486576) and 390px (run 39045981518). A successful intervening run does not prove the underlying contention disappeared. + +The Browser workflow now has three disjoint groups: parallel read-only specs, serial WP-CLI fixture writers, and serial authenticated Gutenberg specs. All existing tests and 30-second timeouts remain; no skips, retries, thresholds, or assertions are relaxed. If a login still times out, inspect the Playwright trace and WordPress server log rather than raising the timeout. This changes CI scheduling only; no theme runtime code or production site is affected. diff --git a/tests/test_editor_login_isolation_contract.py b/tests/test_editor_login_isolation_contract.py new file mode 100644 index 00000000..3ce400d6 --- /dev/null +++ b/tests/test_editor_login_isolation_contract.py @@ -0,0 +1,36 @@ +"""Ensure real WordPress Gutenberg login specs run in an isolated serial group.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class EditorLoginIsolationContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workflow = (ROOT / '.github/workflows/quality.yml').read_text(encoding='utf-8') + cls.editor = (ROOT / 'tests/browser/cjk-heading-editor.spec.js').read_text(encoding='utf-8') + + def test_authenticated_group_exists(self): + self.assertIn('authenticated_specs=()', self.workflow) + self.assertIn("elif grep -Eq 'openAuthenticatedEditor|support/editor-login'", self.workflow) + self.assertIn('authenticated_specs+=("$spec")', self.workflow) + + def test_editor_fixture_is_actually_classified(self): + self.assertIn('openAuthenticatedEditor', self.editor) + + def test_serial_login_without_skipping(self): + self.assertIn('npx playwright test "${authenticated_specs[@]}" --workers=1', self.workflow) + self.assertIn('test "${#authenticated_specs[@]}" -gt 0', self.workflow) + + def test_existing_readonly_and_stateful_suites_remain(self): + self.assertIn('npx playwright test "${readonly_specs[@]}"', self.workflow) + self.assertIn('npx playwright test "${stateful_specs[@]}" --workers=1', self.workflow) + self.assertIn('execFileSync|execSync|spawnSync', self.workflow) + + def test_no_retry_or_timeout_relaxation(self): + self.assertNotIn('npx playwright test "${authenticated_specs[@]}" --workers=1 --retries', self.workflow) + + +if __name__ == '__main__': + unittest.main() From a7873271045425dbca120ecc8deb0fa090538c53 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:12:30 +0900 Subject: [PATCH 17/33] test(portfolio): verify native pagination touch and navigation behavior --- tests/browser/portfolio-core-columns.spec.js | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/browser/portfolio-core-columns.spec.js b/tests/browser/portfolio-core-columns.spec.js index faa89146..98b84843 100644 --- a/tests/browser/portfolio-core-columns.spec.js +++ b/tests/browser/portfolio-core-columns.spec.js @@ -62,3 +62,56 @@ test('RTL/CJK and 200% text enlargement preserve grid boundaries', async ({ page expect(actual.tracks).toBe(columns(6, width(info))); expect(actual.overflow).toBeLessThanOrEqual(1); }); + +test('native Core pagination keeps 44px targets in RTL and enlarged text', async ({ page }) => { + test.skip(!projectPath, 'Requires the real WordPress Portfolio fixture'); + await openTemplate(page); + const nav = page.locator('.slateframe-project-grid .wp-block-query-pagination').first(); + await expect(nav).toBeVisible(); + for (const direction of ['ltr', 'rtl']) { + for (const size of ['100%', '200%']) { + await page.locator('html').evaluate((root, state) => { + root.dir = state.direction; + root.style.fontSize = state.size; + }, { direction, size }); + const metrics = await nav.evaluate((node) => ({ + minTarget: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--slateframe-control')), + links: [...node.querySelectorAll('a[href]')].map((link) => { + const box = link.getBoundingClientRect(); + return { width: box.width, height: box.height }; + }), + current: [...node.querySelectorAll('.page-numbers.current')].map((item) => ({ + tag: item.tagName, tabIndex: item.tabIndex, + })), + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); + expect(metrics.minTarget).toBeGreaterThanOrEqual(44); + expect(metrics.links.length).toBeGreaterThan(0); + expect(metrics.current.length).toBeGreaterThan(0); + for (const link of metrics.links) { + expect(link.width).toBeGreaterThanOrEqual(metrics.minTarget - 1); + expect(link.height).toBeGreaterThanOrEqual(metrics.minTarget - 1); + } + for (const item of metrics.current) { + expect(item.tag).not.toBe('A'); + expect(item.tabIndex).toBe(-1); + } + expect(metrics.overflow).toBeLessThanOrEqual(1); + } + } +}); + +test('native Core pagination navigates to a different real Portfolio page', async ({ page }) => { + test.skip(!projectPath, 'Requires the real WordPress Portfolio fixture'); + await openTemplate(page); + const nav = page.locator('.slateframe-project-grid .wp-block-query-pagination').first(); + await expect(nav).toBeVisible(); + const before = await page.locator('.slateframe-project-grid .wp-block-post-title').allTextContents(); + const link = nav.locator('a[href]').first(); + const destination = await link.getAttribute('href'); + expect(destination).toBeTruthy(); + await link.click(); + await expect(page.locator('.slateframe-project-grid .wp-block-post-template').first()).toBeVisible(); + const after = await page.locator('.slateframe-project-grid .wp-block-post-title').allTextContents(); + expect(after).not.toEqual(before); +}); From 7a023368c1165ad227789f6eea0c5e3dc06b4306 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:15:08 +0900 Subject: [PATCH 18/33] fix(ci): add bounded WordPress.org download retry helper --- bin/retry-wp-core-download.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 bin/retry-wp-core-download.sh diff --git a/bin/retry-wp-core-download.sh b/bin/retry-wp-core-download.sh new file mode 100644 index 00000000..718b4794 --- /dev/null +++ b/bin/retry-wp-core-download.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Retry only transient WordPress.org DNS/connection failures during ephemeral CI setup. +set -euo pipefail +if [[ "$#" -lt 1 ]]; then + echo 'Usage: retry-wp-core-download.sh --path=... --version=...' >&2 + exit 2 +fi +log_file="$(mktemp)" +trap 'rm -f "$log_file"' EXIT +for attempt in 1 2 3; do + if wp core download --force "$@" >"$log_file" 2>&1; then + cat "$log_file" + exit 0 + fi + cat "$log_file" >&2 + if ! grep -Eiq 'cURL error (6|7|28)|[Rr]esolv(ing|e) timed out|[Cc]ould not resolve host|[Tt]emporary failure in name resolution|[Cc]onnection timed out' "$log_file"; then + echo 'WordPress download failed for a non-transient reason; not retrying.' >&2 + exit 1 + fi + if [[ "$attempt" -eq 3 ]]; then + echo 'WordPress.org download still unavailable after three bounded attempts.' >&2 + exit 1 + fi + echo "WordPress.org transient connection failure; retry $((attempt + 1))/3." >&2 + sleep "$((attempt * 5))" +done From fcd3776705dc9baa8373559c84db8d230369e1cd Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:15:44 +0900 Subject: [PATCH 19/33] test(ci): verify bounded WordPress.org retry behavior --- tests/test_retry_wp_core_download.py | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_retry_wp_core_download.py diff --git a/tests/test_retry_wp_core_download.py b/tests/test_retry_wp_core_download.py new file mode 100644 index 00000000..eda619dd --- /dev/null +++ b/tests/test_retry_wp_core_download.py @@ -0,0 +1,60 @@ +"""Keep WP-CLI installation failures visible while tolerating transient DNS outages.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'bin' / 'retry-wp-core-download.sh' + + +class WordPressDownloadRetryTests(unittest.TestCase): + def run_case(self, errors, expected_status): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / 'errors').write_text('\n'.join(errors) + '\n') + (root / 'wp').write_text('''#!/bin/sh +printf '%s\\n' "$*" >> "$TEST_LOG" +count=$(wc -l < "$TEST_LOG") +message=$(sed -n "${count}p" "$TEST_ERRORS") +if [ "$message" = 'SUCCESS' ]; then exit 0; fi +printf '%s\\n' "$message" >&2 +exit 1 +''') + (root / 'sleep').write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$TEST_SLEEP"\n') + (root / 'wp').chmod(0o755) + (root / 'sleep').chmod(0o755) + env = os.environ.copy() + env.update(PATH=str(root) + os.pathsep + env['PATH'], TEST_LOG=str(root / 'log'), + TEST_ERRORS=str(root / 'errors'), TEST_SLEEP=str(root / 'sleep-log')) + result = subprocess.run(['bash', str(SCRIPT), '--path=/tmp/ci-wp', '--version=latest'], + env=env, text=True, capture_output=True, timeout=15) + calls = (root / 'log').read_text().splitlines() + self.assertEqual(result.returncode, expected_status, result.stderr) + self.assertTrue(all('core download --force --path=/tmp/ci-wp --version=latest' == c for c in calls)) + return calls, (root / 'sleep-log').read_text().splitlines() if (root / 'sleep-log').exists() else [], result + + def test_immediate_success_has_no_retry(self): + calls, sleeps, _ = self.run_case(['SUCCESS'], 0) + self.assertEqual((len(calls), sleeps), (1, [])) + + def test_dns_failure_recovers_without_changing_wordpress_version(self): + calls, sleeps, _ = self.run_case(['cURL error 28: Resolving timed out after 10000 milliseconds.', 'SUCCESS'], 0) + self.assertEqual((len(calls), sleeps), (2, ['5'])) + + def test_nontransient_checksum_failure_is_not_retried(self): + calls, sleeps, _ = self.run_case(['Checksum mismatch'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + + def test_persistent_network_failure_stops_after_three(self): + calls, sleeps, result = self.run_case(['cURL error 6: Could not resolve host'] * 3, 1) + self.assertEqual((len(calls), sleeps), (3, ['5', '10'])) + self.assertIn('after three bounded attempts', result.stderr) + + def test_http_404_is_not_hidden(self): + calls, sleeps, _ = self.run_case(['Error: 404 Not Found'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + + +if __name__ == '__main__': + unittest.main() From 5c7e4155f1a13b6367322feeacb9a4edb28f0193 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:40:41 +0900 Subject: [PATCH 20/33] test(ci): inventory all exact-HEAD browser screenshots --- tests/verify_browser_inventory.py | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/verify_browser_inventory.py diff --git a/tests/verify_browser_inventory.py b/tests/verify_browser_inventory.py new file mode 100644 index 00000000..e4bdbf6f --- /dev/null +++ b/tests/verify_browser_inventory.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Exact-SHA inventory for every real WordPress/Chromium Browser screenshot.""" +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +from verify_footnotes_screenshots import validate_png, read_bounded_regular_file, reject_linked_ancestors + +ROOT = Path('test-artifacts/screenshots') +MANIFEST = 'BROWSER_SCREENSHOT_INVENTORY.json' +METADATA = frozenset(('HEAD_SHA.txt', 'VISUAL_EVIDENCE.json', MANIFEST)) +FULL_PAGE = re.compile(r'appearance-(compact|default|spacious)-viewport-(390|1440)-(?!footnotes\.png|content-pagination\.png)[a-z0-9-]+\.png\Z') +NAME = re.compile(r'[a-zA-Z0-9][a-zA-Z0-9_.-]*\.png\Z') + + +def scan(root): + reject_linked_ancestors(root) + if not root.is_dir(): + raise ValueError('Missing screenshot directory') + records = {} + total = 0 + for path in root.iterdir(): + if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1: + raise ValueError('Linked or nonregular artifact entry') + if path.suffix != '.png': + if path.name not in METADATA: + raise ValueError('Unexpected non-screenshot artifact file') + continue + if not NAME.fullmatch(path.name): + raise ValueError('Unsafe screenshot name') + data = read_bounded_regular_file(path, 20 * 1024 * 1024) + if len(data) < 33 or not data.startswith(b'\x89PNG\r\n\x1a\n'): + raise ValueError('Invalid screenshot PNG') + width = int.from_bytes(data[16:20], 'big') + if not 200 <= width <= 1924: + raise ValueError('Invalid screenshot width') + actual_width, height = validate_png(path, width) + match = FULL_PAGE.fullmatch(path.name) + if match and actual_width != int(match.group(2)): + raise ValueError('Full-page viewport width mismatch') + total += len(data) + if total > 1024 * 1024 * 1024: + raise ValueError('Screenshot artifact too large') + records[path.name] = dict(sha256=hashlib.sha256(data).hexdigest(), + width=actual_width, height=height, bytes=len(data)) + if len(records) > 5000: + raise ValueError('Too many screenshot files') + if len(records) < 48: + raise ValueError('Insufficient screenshot evidence') + return dict(sorted(records.items())) + + +def bounded_json(path): + if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1: + raise ValueError('Missing or linked evidence manifest') + return json.loads(read_bounded_regular_file(path, 1024 * 1024)) + + +def identity(root): + sha = os.environ.get('SLATEFRAME_EVIDENCE_SHA', '') + if not re.fullmatch('[0-9a-f]{40}', sha): + raise ValueError('Invalid expected HEAD SHA') + if read_bounded_regular_file(root / 'HEAD_SHA.txt', 64).decode('ascii').strip() != sha: + raise ValueError('Screenshot HEAD SHA mismatch') + run = os.environ.get('GITHUB_RUN_ID') or None + attempt = os.environ.get('GITHUB_RUN_ATTEMPT') or None + if os.environ.get('GITHUB_ACTIONS') == 'true': + if subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() != sha: + raise ValueError('Checkout HEAD mismatch') + if not run or not run.isdecimal() or not attempt or not attempt.isdecimal(): + raise ValueError('Missing Actions run identity') + return sha, run, attempt + + +def check_legacy(root, sha, records): + old = bounded_json(root / 'VISUAL_EVIDENCE.json') + if old.get('head_sha') != sha or not isinstance(old.get('files'), list) or len(old['files']) < 18: + raise ValueError('Legacy screenshot evidence missing or stale') + seen = set() + for item in old['files']: + if not isinstance(item, dict) or item.get('name') in seen: + raise ValueError('Duplicate legacy evidence entry') + name = item['name'] + seen.add(name) + if name not in records or any(item.get(k) != records[name][k] for k in ('sha256', 'width', 'height')): + raise ValueError('Legacy evidence hash mismatch') + + +def run(mode, root=ROOT): + sha, run_id, attempt = identity(root) + target = root / MANIFEST + if target.is_symlink() or (target.exists() and target.stat().st_nlink != 1): + raise ValueError('Linked inventory destination') + if mode == 'generate': + target.unlink(missing_ok=True) + records = scan(root) + check_legacy(root, sha, records) + expected = dict(schema_version=1, head_sha=sha, run_id=run_id, + run_attempt=attempt, screenshots=records) + if mode == 'generate': + temporary = None + try: + with tempfile.NamedTemporaryFile('w', dir=root, prefix='.inventory-', delete=False) as handle: + temporary = Path(handle.name) + json.dump(expected, handle, sort_keys=True, indent=2) + handle.write('\n') + temporary.replace(target) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + if bounded_json(target) != expected: + raise ValueError('Incomplete, stale or tampered screenshot inventory') + return len(records) + + +if __name__ == '__main__': + try: + if len(sys.argv) != 2 or sys.argv[1] not in ('generate', 'verify'): + raise ValueError('Usage: verify_browser_inventory.py generate|verify') + print(f'Validated {run(sys.argv[1])} exact-HEAD Browser PNGs') + except (ValueError, OSError, UnicodeError, subprocess.CalledProcessError) as error: + sys.exit(f'Browser inventory rejected: {error}') From bfb646386b2e275c224697e3f43ec339c2390b20 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:41:00 +0900 Subject: [PATCH 21/33] test(ci): cover screenshot inventory tampering and artifact leakage --- tests/test_browser_inventory.py | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/test_browser_inventory.py diff --git a/tests/test_browser_inventory.py b/tests/test_browser_inventory.py new file mode 100644 index 00000000..6b1d646f --- /dev/null +++ b/tests/test_browser_inventory.py @@ -0,0 +1,115 @@ +"""Regression coverage for the complete Browser screenshot artifact gate.""" +import hashlib +import json +import os +from pathlib import Path +import struct +import tempfile +import unittest +from unittest.mock import patch +import zlib + +from verify_browser_inventory import MANIFEST, run + +SHA = 'a' * 40 + + +def chunk(kind, payload=b''): + return struct.pack('>I', len(payload)) + kind + payload + struct.pack('>I', zlib.crc32(kind + payload) & 0xffffffff) + + +def png(width=390, height=24): + raster = (b'\x00' + b'\x80\x80\x80' * width) * height + return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raster)) + chunk(b'IEND') + + +class InventoryContract(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.files = [] + for profile in ('compact', 'default', 'spacious'): + for width in (390, 1440): + for surface in ('portfolio', 'photography', 'knowledge', 'post', 'home-controls', 'search-controls', 'page', 'photography-lightbox'): + name = f'appearance-{profile}-viewport-{width}-{surface}.png' + (self.root / name).write_bytes(png(width)) + self.files.append(name) + legacy = [] + for name in self.files[:18]: + data = (self.root / name).read_bytes() + legacy.append(dict(name=name, sha256=hashlib.sha256(data).hexdigest(), width=390 if '-390-' in name else 1440, height=24)) + (self.root / 'HEAD_SHA.txt').write_text(SHA + '\n') + (self.root / 'VISUAL_EVIDENCE.json').write_text(json.dumps(dict(head_sha=SHA, files=legacy))) + context = patch.dict(os.environ, {'SLATEFRAME_EVIDENCE_SHA': SHA, 'GITHUB_ACTIONS': 'false'}, clear=False) + context.start() + self.addCleanup(context.stop) + + def test_generate_verify_all_48(self): + self.assertEqual(run('generate', self.root), 48) + self.assertEqual(run('verify', self.root), 48) + + def test_rejects_missing_manifest(self): + with self.assertRaises(ValueError): + run('verify', self.root) + + def test_rejects_tampering(self): + run('generate', self.root) + (self.root / self.files[-1]).write_bytes(png(390)) + with self.assertRaises(ValueError): + run('verify', self.root) + + def test_rejects_unexpected_file(self): + (self.root / '.env').write_text('PRIVATE=1') + with self.assertRaisesRegex(ValueError, 'non-screenshot'): + run('generate', self.root) + + def test_rejects_symlink(self): + (self.root / 'linked.png').symlink_to(self.root / self.files[0]) + with self.assertRaises(ValueError): + run('generate', self.root) + + def test_rejects_hardlink(self): + os.link(self.root / self.files[0], self.root / 'hardlink.png') + with self.assertRaises(ValueError): + run('generate', self.root) + + def test_rejects_wrong_fullpage_width(self): + (self.root / self.files[0]).write_bytes(png(375)) + with self.assertRaisesRegex(ValueError, 'width mismatch'): + run('generate', self.root) + + def test_accepts_element_crop(self): + (self.root / 'appearance-compact-viewport-390-footnotes.png').write_bytes(png(288)) + self.assertEqual(run('generate', self.root), 49) + + def test_rejects_legacy_hash_mismatch(self): + data = json.loads((self.root / 'VISUAL_EVIDENCE.json').read_text()) + data['files'][0]['sha256'] = '0' * 64 + (self.root / 'VISUAL_EVIDENCE.json').write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, 'Legacy evidence'): + run('generate', self.root) + + def test_rejects_run_identity_change(self): + with patch.dict(os.environ, {'GITHUB_RUN_ID': '12', 'GITHUB_RUN_ATTEMPT': '1'}): + run('generate', self.root) + with patch.dict(os.environ, {'GITHUB_RUN_ID': '13', 'GITHUB_RUN_ATTEMPT': '1'}): + with self.assertRaisesRegex(ValueError, 'stale'): + run('verify', self.root) + + def test_clears_stale_on_failed_regeneration(self): + run('generate', self.root) + (self.root / 'unexpected.log').write_text('private') + with self.assertRaises(ValueError): + run('generate', self.root) + self.assertFalse((self.root / MANIFEST).exists()) + + def test_rejects_oversized_manifest(self): + run('generate', self.root) + (self.root / MANIFEST).write_bytes(b' ' * (1024 * 1024 + 1)) + with self.assertRaises(ValueError): + run('verify', self.root) + + +if __name__ == '__main__': + unittest.main() From e120e66e8504adf968507ac70f958f3cc58a83ad Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 01:41:14 +0900 Subject: [PATCH 22/33] ci(browser): gate artifact upload on complete exact-HEAD PNG inventory --- .github/workflows/quality.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5b242bb4..2be2d02c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -931,6 +931,8 @@ jobs: done python3 bin/check-screenshot.py test-artifacts/screenshots/wordpress-org-screenshot-candidate.png python3 tests/verify_footnotes_screenshots.py test-artifacts/screenshots "$SLATEFRAME_EVIDENCE_SHA" + python3 tests/verify_browser_inventory.py generate + python3 tests/verify_browser_inventory.py verify - name: Upload browser artifacts if: ${{ always() }} uses: actions/upload-artifact@v4 From d34220b9f849cbb13968990c34a01ae076086db4 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 05:05:16 +0900 Subject: [PATCH 23/33] test(browser): validate all responsive full-page screenshot widths --- tests/test_browser_inventory.py | 23 +++++++++++++++++++++++ tests/verify_browser_inventory.py | 10 ++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/test_browser_inventory.py b/tests/test_browser_inventory.py index 6b1d646f..e326edd8 100644 --- a/tests/test_browser_inventory.py +++ b/tests/test_browser_inventory.py @@ -83,6 +83,29 @@ def test_accepts_element_crop(self): (self.root / 'appearance-compact-viewport-390-footnotes.png').write_bytes(png(288)) self.assertEqual(run('generate', self.root), 49) + def test_all_responsive_full_pages_enforce_real_viewport_width(self): + for width in (320, 375, 390, 412, 768, 1440, 1920): + with self.subTest(width=width): + name = self.root / f'viewport-{width}-page.png' + name.write_bytes(png(width)) + self.assertEqual(run('generate', self.root), 49) + name.write_bytes(png(width - 1)) + with self.assertRaisesRegex(ValueError, 'width mismatch'): + run('generate', self.root) + name.unlink() + + def test_non_fullpage_crops_keep_their_actual_width(self): + for name, width in ( + ('compact-nav-viewport-320-200-forced-colors.png', 288), + ('gutenberg-viewport-1440-portfolio-first.png', 368), + ('appearance-spacious-viewport-1440-content-pagination.png', 800), + ): + with self.subTest(name=name): + path = self.root / name + path.write_bytes(png(width)) + self.assertEqual(run('generate', self.root), 49) + path.unlink() + def test_rejects_legacy_hash_mismatch(self): data = json.loads((self.root / 'VISUAL_EVIDENCE.json').read_text()) data['files'][0]['sha256'] = '0' * 64 diff --git a/tests/verify_browser_inventory.py b/tests/verify_browser_inventory.py index e4bdbf6f..69c6506f 100644 --- a/tests/verify_browser_inventory.py +++ b/tests/verify_browser_inventory.py @@ -14,7 +14,13 @@ ROOT = Path('test-artifacts/screenshots') MANIFEST = 'BROWSER_SCREENSHOT_INVENTORY.json' METADATA = frozenset(('HEAD_SHA.txt', 'VISUAL_EVIDENCE.json', MANIFEST)) -FULL_PAGE = re.compile(r'appearance-(compact|default|spacious)-viewport-(390|1440)-(?!footnotes\.png|content-pagination\.png)[a-z0-9-]+\.png\Z') +# Viewport-prefixed files are full-page evidence; Gutenberg and control crops +# use distinct prefixes. The two Appearance component crops remain exempt. +FULL_PAGE = re.compile( + r'(?:appearance-(compact|default|spacious)-viewport-(390|1440)-' + r'(?!footnotes\.png|content-pagination\.png)[a-z0-9-]+' + r'|viewport-(320|375|390|412|768|1440|1920)-[a-z0-9-]+)\.png\Z' +) NAME = re.compile(r'[a-zA-Z0-9][a-zA-Z0-9_.-]*\.png\Z') @@ -41,7 +47,7 @@ def scan(root): raise ValueError('Invalid screenshot width') actual_width, height = validate_png(path, width) match = FULL_PAGE.fullmatch(path.name) - if match and actual_width != int(match.group(2)): + if match and actual_width != int(match.group(2) or match.group(3)): raise ValueError('Full-page viewport width mismatch') total += len(data) if total > 1024 * 1024 * 1024: From e91102ab9eb82bf41dd125b6ad5e6f1e761902da Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 05:07:25 +0900 Subject: [PATCH 24/33] ci(browser): retain failing Playwright trace for Gutenberg diagnosis --- .github/workflows/quality.yml | 3 +++ tests/test_browser_failure_trace_contract.py | 25 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/test_browser_failure_trace_contract.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2be2d02c..be6894e4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -839,6 +839,8 @@ jobs: test "${#stateful_specs[@]}" -gt 0 test "${#authenticated_specs[@]}" -gt 0 printf 'Parallel read-only: %s; isolated WP fixture: %s; isolated Gutenberg login: %s\\n' "${#readonly_specs[@]}" "${#stateful_specs[@]}" "${#authenticated_specs[@]}" + # Preserve failure traces before a later suite clears test-results. + trap 'status=$?; if [ "$status" -ne 0 ] && [ -d test-results ]; then mkdir -p test-artifacts/browser-failure; cp -a test-results test-artifacts/browser-failure/responsive; fi' EXIT npx playwright test "${readonly_specs[@]}" npx playwright test "${stateful_specs[@]}" --workers=1 npx playwright test "${authenticated_specs[@]}" --workers=1 @@ -940,6 +942,7 @@ jobs: name: slateframe-browser path: | test-artifacts/screenshots/ + test-artifacts/browser-failure/ playwright-report/ test-results/ /tmp/slateframe-server.log diff --git a/tests/test_browser_failure_trace_contract.py b/tests/test_browser_failure_trace_contract.py new file mode 100644 index 00000000..ba6144bb --- /dev/null +++ b/tests/test_browser_failure_trace_contract.py @@ -0,0 +1,25 @@ +"""Keep the first failing Browser smoke trace available for diagnosis.""" +from pathlib import Path +import unittest + +WORKFLOW = Path(__file__).resolve().parents[1] / '.github/workflows/quality.yml' + +class BrowserFailureTraceContract(unittest.TestCase): + def test_failed_suite_preserves_trace_before_exit(self): + content = WORKFLOW.read_text() + self.assertIn("trap 'status=$?;", content) + self.assertIn('cp -a test-results test-artifacts/browser-failure/responsive', content) + self.assertIn('npx playwright test "${readonly_specs[@]}"', content) + self.assertLess(content.index("trap 'status=$?;"), content.index('npx playwright test "${readonly_specs[@]}"')) + + def test_artifact_upload_includes_preserved_trace(self): + content = WORKFLOW.read_text() + self.assertIn('test-artifacts/browser-failure/', content) + + def test_no_coverage_reduction(self): + content = WORKFLOW.read_text() + self.assertIn('npx playwright test "${stateful_specs[@]}" --workers=1', content) + self.assertIn('npx playwright test "${authenticated_specs[@]}" --workers=1', content) + +if __name__ == '__main__': + unittest.main() From 609ebbc3904bdf646ff9abbc5411ec66d2b8fe2e Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 05:18:34 +0900 Subject: [PATCH 25/33] fix(photography): decode lazy Core media before full-page evidence --- tests/browser/photography-class-only.spec.js | 4 ++- .../photography-native-sequence.spec.js | 4 ++- .../photography-raster-lightbox.spec.js | 5 +++- .../browser/photography-zoom-caption.spec.js | 2 ++ tests/browser/test-visual-evidence-helper.cjs | 8 ++++-- tests/browser/visual-evidence.js | 2 +- ...st_photography_lazy_screenshot_contract.py | 28 +++++++++++++++++++ 7 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/test_photography_lazy_screenshot_contract.py diff --git a/tests/browser/photography-class-only.spec.js b/tests/browser/photography-class-only.spec.js index 881b76b6..77476999 100644 --- a/tests/browser/photography-class-only.spec.js +++ b/tests/browser/photography-class-only.spec.js @@ -1,6 +1,7 @@ const { test, expect } = require('@playwright/test'); const fs = require('node:fs/promises'); const path = require('node:path'); +const { settleImagesForEvidence } = require('./visual-evidence'); const fixture = process.env.SLATEFRAME_CLASS_ONLY_GALLERY_PATH; test.skip(!fixture, 'Real WordPress class-only Gallery fixture is required.'); async function gallery(page) { @@ -118,7 +119,8 @@ test('two-frame Gallery centers final image in LTR and RTL at every viewport', a test('class-only Gallery records full WordPress screenshot',async ({page})=>{ const width=page.viewportSize().width; test.skip(![390,1440].includes(width),'Representative viewports'); - await gallery(page);await page.evaluate(()=>scrollTo(0,0)); + await gallery(page); + await settleImagesForEvidence(page, '#main-content .wp-block-image img', 2); const dir=path.resolve('test-artifacts/photography-lightbox'); await fs.mkdir(dir,{recursive:true}); await page.screenshot({path:path.join(dir,'class-only-sequence-'+width+'.png'),fullPage:true,animations:'disabled'}); diff --git a/tests/browser/photography-native-sequence.spec.js b/tests/browser/photography-native-sequence.spec.js index fd91c7fa..5b568e5c 100644 --- a/tests/browser/photography-native-sequence.spec.js +++ b/tests/browser/photography-native-sequence.spec.js @@ -2,6 +2,7 @@ const { test, expect } = require('@playwright/test'); const AxeBuilder = require('@axe-core/playwright').default; const fs = require('node:fs/promises'); const path = require('node:path'); +const { settlePhotographyImagesForEvidence } = require('./visual-evidence'); const fixture = process.env.SLATEFRAME_RASTER_PHOTO_PATH; test.skip(!fixture, 'Real WordPress Gallery fixture is required.'); async function gallery(page) { @@ -101,7 +102,8 @@ test('two-frame Gallery centers final image in LTR and RTL at every viewport', a test('full WordPress page screenshots',async ({page})=>{ const width=page.viewportSize().width; test.skip(![320,390,768,1440,1920].includes(width),'Representative viewports'); - await gallery(page);await page.evaluate(()=>scrollTo(0,0)); + await gallery(page); + await settlePhotographyImagesForEvidence(page); const dir=path.resolve('test-artifacts/photography-lightbox'); await fs.mkdir(dir,{recursive:true}); await page.screenshot({path:path.join(dir,'native-sequence-page-'+width+'.png'),fullPage:true,animations:'disabled'}); diff --git a/tests/browser/photography-raster-lightbox.spec.js b/tests/browser/photography-raster-lightbox.spec.js index 194beac9..f2beb52a 100644 --- a/tests/browser/photography-raster-lightbox.spec.js +++ b/tests/browser/photography-raster-lightbox.spec.js @@ -2,6 +2,7 @@ const AxeBuilder = require('@axe-core/playwright').default; const { test, expect } = require('@playwright/test'); const fs = require('node:fs/promises'); const path = require('node:path'); +const { settlePhotographyImagesForEvidence } = require('./visual-evidence'); const rasterPath = process.env.SLATEFRAME_RASTER_PHOTO_PATH; const representativeWidths = [320, 390, 768, 1440, 1920]; @@ -470,7 +471,9 @@ test('native lightbox captures representative mobile and desktop evidence', asyn await openRasterPhotography(page); const screenshotDir = path.resolve('test-artifacts/photography-lightbox'); await fs.mkdir(screenshotDir, { recursive: true }); - await page.screenshot({ path: path.join(screenshotDir, `photography-page-${testInfo.project.name}.png`), fullPage: true }); + // Force all lazy Core images to load, decode and settle before full-page evidence. + await settlePhotographyImagesForEvidence(page); + await page.screenshot({ path: path.join(screenshotDir, `photography-page-${testInfo.project.name}.png`), fullPage: true, animations: 'disabled' }); const feature = await openLightbox(page, 'keyboard'); await expectLightboxSettled(feature.dialog, feature.enlarged, 'landscape screenshot evidence should capture the settled lightbox'); diff --git a/tests/browser/photography-zoom-caption.spec.js b/tests/browser/photography-zoom-caption.spec.js index f9559975..9cd1dc6c 100644 --- a/tests/browser/photography-zoom-caption.spec.js +++ b/tests/browser/photography-zoom-caption.spec.js @@ -1,6 +1,7 @@ const {test,expect}=require('@playwright/test'); const fs=require('node:fs/promises'); const path=require('node:path'); +const { settleImagesForEvidence } = require('./visual-evidence'); const variants=[['native','SLATEFRAME_RASTER_PHOTO_PATH','.browser-native-sequence'],['class-only','SLATEFRAME_CLASS_ONLY_GALLERY_PATH','.browser-class-only-sequence']]; const longCaption=('中文摄影说明 日本語の長いキャプション العربية — A translated caption must remain legible at browser zoom. ').repeat(3); test.beforeEach(({},info)=>test.skip(info.project.use.viewport?.width!==1440,'Representative viewport only')); @@ -28,6 +29,7 @@ for(const [name,env,selector] of variants){ expect(m.centers).toBeLessThanOrEqual(2); expect(m.below).toBe(true); expect(m.overflow).toBeLessThanOrEqual(1); + await settleImagesForEvidence(page, '#main-content .wp-block-image img', 2); const dest=path.resolve('test-artifacts/photography-lightbox'); await fs.mkdir(dest,{recursive:true}); await page.screenshot({path:path.join(dest,`zoom-caption-${name}-${dir}-360x225.png`),fullPage:true,animations:'disabled'}); diff --git a/tests/browser/test-visual-evidence-helper.cjs b/tests/browser/test-visual-evidence-helper.cjs index bc9bb262..5963f8e7 100644 --- a/tests/browser/test-visual-evidence-helper.cjs +++ b/tests/browser/test-visual-evidence-helper.cjs @@ -16,7 +16,7 @@ const context = { module: { exports: {} }, require: (id) => { return { expect }; } }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'visual-evidence.js'), 'utf8'), context); -const { settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence } = context.module.exports; +const { settleImagesForEvidence, settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence } = context.module.exports; function fixture(count, options = {}) { const log = []; @@ -47,6 +47,10 @@ function fixture(count, options = {}) { const portfolio = fixture(4); await settlePortfolioImagesForEvidence(portfolio.page); assert.equal(portfolio.log.filter((x) => x === 'decode').length, 4); + const crop = fixture(2); + await settleImagesForEvidence(crop.page, '#main-content .wp-block-image img', 2); + assert.equal(crop.log.filter((x) => x === 'decode').length, 2); + await assert.rejects(() => settleImagesForEvidence(fixture(1).page, '#main-content .wp-block-image img', 2)); const photography = fixture(5); await settlePhotographyImagesForEvidence(photography.page); assert.equal(photography.log.filter((x) => x === 'decode').length, 5); @@ -60,5 +64,5 @@ function fixture(count, options = {}) { await settleLightboxImageForEvidence(fixture(1).page.locator().nth(0)); await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { brokenAt: 0 }).page.locator().nth(0))); await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { decodeFailsAt: 0 }).page.locator().nth(0))); - console.log('12/12 visual evidence helper scenarios passed'); + console.log('14/14 visual evidence helper scenarios passed'); })().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/browser/visual-evidence.js b/tests/browser/visual-evidence.js index 6bf15bdc..382a258d 100644 --- a/tests/browser/visual-evidence.js +++ b/tests/browser/visual-evidence.js @@ -36,4 +36,4 @@ async function settleLightboxImageForEvidence(image) { await image.evaluate((node) => node.decode()); } -module.exports = { settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence }; +module.exports = { settleImagesForEvidence, settlePortfolioImagesForEvidence, settlePhotographyImagesForEvidence, settleLightboxImageForEvidence }; diff --git a/tests/test_photography_lazy_screenshot_contract.py b/tests/test_photography_lazy_screenshot_contract.py new file mode 100644 index 00000000..18056404 --- /dev/null +++ b/tests/test_photography_lazy_screenshot_contract.py @@ -0,0 +1,28 @@ +"""Full-page Photography screenshots must settle lazy Core media before capture.""" +from pathlib import Path +import unittest +ROOT = Path(__file__).resolve().parents[1] / "tests/browser" + +class PhotographyLazyScreenshotContract(unittest.TestCase): + def test_raster_fullpage_settles_every_image(self): + text = (ROOT / "photography-raster-lightbox.spec.js").read_text() + self.assertIn("await settlePhotographyImagesForEvidence(page);", text) + self.assertLess(text.index("await settlePhotographyImagesForEvidence(page);"), text.index("photography-page-${testInfo.project.name}.png")) + + def test_native_fullpage_settles_every_image(self): + text = (ROOT / "photography-native-sequence.spec.js").read_text() + self.assertIn("await settlePhotographyImagesForEvidence(page);", text) + + def test_class_only_and_zoom_settle_all_core_images(self): + for name in ("photography-class-only.spec.js", "photography-zoom-caption.spec.js"): + with self.subTest(name=name): + self.assertIn("await settleImagesForEvidence(page, '#main-content .wp-block-image img', 2);", (ROOT / name).read_text()) + + def test_shared_helper_keeps_real_decode_and_scroll_restore(self): + text = (ROOT / "visual-evidence.js").read_text() + self.assertIn("await image.scrollIntoViewIfNeeded()", text) + self.assertIn("await image.evaluate((node) => node.decode())", text) + self.assertIn("Math.max(Math.abs(window.scrollX), Math.abs(window.scrollY))", text) + +if __name__ == '__main__': + unittest.main() From 2848002bba883a3939455be61041026a73043b02 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 05:19:39 +0900 Subject: [PATCH 26/33] test(photography): cover lazy image activation and stalled loading --- tests/browser/test-visual-evidence-helper.cjs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/browser/test-visual-evidence-helper.cjs b/tests/browser/test-visual-evidence-helper.cjs index 5963f8e7..ac370786 100644 --- a/tests/browser/test-visual-evidence-helper.cjs +++ b/tests/browser/test-visual-evidence-helper.cjs @@ -21,9 +21,9 @@ const { settleImagesForEvidence, settlePortfolioImagesForEvidence, settlePhotogr function fixture(count, options = {}) { const log = []; const nodes = Array.from({ length: count }, (_, i) => ({ - complete: true, - naturalWidth: options.brokenAt === i ? 0 : 160, - naturalHeight: options.zeroHeightAt === i ? 0 : 120, + complete: options.lazyAt !== i && options.stuckLazyAt !== i, + naturalWidth: options.brokenAt === i || options.lazyAt === i || options.stuckLazyAt === i ? 0 : 160, + naturalHeight: options.zeroHeightAt === i || options.lazyAt === i || options.stuckLazyAt === i ? 0 : 120, decode: async () => { log.push('decode'); if (options.decodeFailsAt === i) throw Error('decode failed'); }, })); const page = { @@ -31,7 +31,14 @@ function fixture(count, options = {}) { count: async () => nodes.length, nth: (i) => ({ visible: options.hiddenAt !== i, - scrollIntoViewIfNeeded: async () => log.push('scroll'), + scrollIntoViewIfNeeded: async () => { + log.push('scroll'); + if (options.lazyAt === i) { + nodes[i].complete = true; + nodes[i].naturalWidth = 160; + nodes[i].naturalHeight = 120; + } + }, evaluate: async (fn) => fn(nodes[i]), }), }), @@ -51,6 +58,10 @@ function fixture(count, options = {}) { await settleImagesForEvidence(crop.page, '#main-content .wp-block-image img', 2); assert.equal(crop.log.filter((x) => x === 'decode').length, 2); await assert.rejects(() => settleImagesForEvidence(fixture(1).page, '#main-content .wp-block-image img', 2)); + const lazy = fixture(2, { lazyAt: 1 }); + await settleImagesForEvidence(lazy.page, '#main-content .wp-block-image img', 2); + assert.equal(lazy.log.filter((x) => x === 'decode').length, 2); + await assert.rejects(() => settleImagesForEvidence(fixture(2, { stuckLazyAt: 1 }).page, '#main-content .wp-block-image img', 2)); const photography = fixture(5); await settlePhotographyImagesForEvidence(photography.page); assert.equal(photography.log.filter((x) => x === 'decode').length, 5); @@ -64,5 +75,5 @@ function fixture(count, options = {}) { await settleLightboxImageForEvidence(fixture(1).page.locator().nth(0)); await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { brokenAt: 0 }).page.locator().nth(0))); await assert.rejects(() => settleLightboxImageForEvidence(fixture(1, { decodeFailsAt: 0 }).page.locator().nth(0))); - console.log('14/14 visual evidence helper scenarios passed'); + console.log('16/16 visual evidence helper scenarios passed'); })().catch((error) => { console.error(error); process.exitCode = 1; }); From c5e818a7877e363710912ccd393acef69c6bcf1e Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 05:39:27 +0900 Subject: [PATCH 27/33] test(photography): hash all Core crop and zoom screenshots in exact-SHA evidence --- tests/test_verify_gallery_screenshots.py | 18 +++++++++++++++- tests/verify_gallery_screenshots.py | 27 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/test_verify_gallery_screenshots.py b/tests/test_verify_gallery_screenshots.py index 09a8b142..53fa2a3b 100644 --- a/tests/test_verify_gallery_screenshots.py +++ b/tests/test_verify_gallery_screenshots.py @@ -103,6 +103,22 @@ def make_all(self): self.path.unlink() for i, (name, width) in enumerate(gate.REQUIRED.items()): (self.folder / name).write_bytes(png(width, color=1 + i)) + for i, (name, viewport) in enumerate(gate.SUPPLEMENTAL.items()): + (self.folder / name).write_bytes(png(min(390, viewport), color=100 + i)) + + def test_missing_auxiliary_screenshot_is_rejected(self): + self.make_all() + (self.folder / 'core-sequence-320.png').unlink() + with patch.dict(os.environ, {'GITHUB_ACTIONS': 'false'}, clear=True): + with self.assertRaisesRegex(ValueError, 'auxiliary Gallery screenshot'): + gate.verify() + + def test_auxiliary_crop_must_fit_its_viewport(self): + self.make_all() + (self.folder / 'core-sequence-320.png').write_bytes(png(321)) + with patch.dict(os.environ, {'GITHUB_ACTIONS': 'false'}, clear=True): + with self.assertRaisesRegex(ValueError, 'exceeds viewport'): + gate.verify() def test_complete_manifest(self): self.make_all() @@ -110,7 +126,7 @@ def test_complete_manifest(self): gate.verify() import json manifest = json.loads((self.folder / 'gallery-evidence-manifest.json').read_text()) - self.assertEqual(len(manifest['screenshots']), len(gate.REQUIRED)) + self.assertEqual(len(manifest['screenshots']), len(gate.REQUIRED) + len(gate.SUPPLEMENTAL)) def test_duplicate_cross_scene_rejected(self): self.make_all() diff --git a/tests/verify_gallery_screenshots.py b/tests/verify_gallery_screenshots.py index d6c99568..29f6c9fb 100644 --- a/tests/verify_gallery_screenshots.py +++ b/tests/verify_gallery_screenshots.py @@ -25,6 +25,13 @@ **{f'zoom-caption-{kind}-{direction}-360x225.png': 360 for kind in ('native', 'class-only') for direction in ('ltr', 'rtl')}, } +# Include auxiliary Core crops and zoom captures in the exact-SHA hash manifest. +# Crop widths are measured from PNG data, never assumed equal to the viewport. +SUPPLEMENTAL = { + **{f'core-sequence-{w}.png': w for w in (320, 375, 390, 412, 768, 1440, 1920)}, + **{f'sequence-caption-rtl-zoom-{w}.png': w for w in (390, 1440, 1920)}, + 'sequence-caption-textzoom-360x225.png': 360, +} MAGIC = b'\x89PNG\r\n\x1a\n' @@ -194,6 +201,26 @@ def verify(): raise ValueError(f'duplicate screenshot evidence: {pair}') seen[record['pixel_sha256']] = filename records[filename] = record + # Auxiliary screenshots are required evidence too, but some are element + # crops narrower than their browser viewport. Validate their real PNG + # dimensions and bind their hashes to this run without inventing width. + for filename, viewport_width in SUPPLEMENTAL.items(): + path = ROOT / filename + safe_path(path) + if not path.is_file() or path.is_symlink() or path.stat().st_nlink != 1: + raise ValueError(f'missing or linked auxiliary Gallery screenshot: {path}') + with path.open('rb') as handle: + header = handle.read(24) + if len(header) < 24 or not header.startswith(MAGIC): + raise ValueError(f'invalid auxiliary Gallery PNG: {path}') + actual_width = int.from_bytes(header[16:20], 'big') + if not 200 <= actual_width <= viewport_width: + raise ValueError(f'auxiliary Gallery crop exceeds viewport: {path}') + record = inspect_png(path, actual_width) + if record['pixel_sha256'] in seen: + raise ValueError(f'duplicate auxiliary Gallery screenshot: {filename}') + seen[record['pixel_sha256']] = filename + records[filename] = record run_id = os.environ.get('GITHUB_RUN_ID') attempt = os.environ.get('GITHUB_RUN_ATTEMPT') if commit and (not (run_id or '').isdecimal() or not (attempt or '').isdecimal()): From a8851cf4a8992d87f02f06007422a2b3088c35a8 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 07:11:17 +0900 Subject: [PATCH 28/33] test(visual): require complete appearance and responsive screenshot coverage --- tests/test_browser_inventory.py | 44 ++++++++++++++++++++++++++----- tests/verify_browser_inventory.py | 26 +++++++++++++++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/tests/test_browser_inventory.py b/tests/test_browser_inventory.py index e326edd8..11b0903f 100644 --- a/tests/test_browser_inventory.py +++ b/tests/test_browser_inventory.py @@ -35,6 +35,10 @@ def setUp(self): name = f'appearance-{profile}-viewport-{width}-{surface}.png' (self.root / name).write_bytes(png(width)) self.files.append(name) + for width in (320, 375, 390, 412, 768, 1440, 1920): + name = f'viewport-{width}-page.png' + (self.root / name).write_bytes(png(width)) + self.files.append(name) legacy = [] for name in self.files[:18]: data = (self.root / name).read_bytes() @@ -45,9 +49,9 @@ def setUp(self): context.start() self.addCleanup(context.stop) - def test_generate_verify_all_48(self): - self.assertEqual(run('generate', self.root), 48) - self.assertEqual(run('verify', self.root), 48) + def test_generate_verify_all_required_55(self): + self.assertEqual(run('generate', self.root), 55) + self.assertEqual(run('verify', self.root), 55) def test_rejects_missing_manifest(self): with self.assertRaises(ValueError): @@ -81,18 +85,19 @@ def test_rejects_wrong_fullpage_width(self): def test_accepts_element_crop(self): (self.root / 'appearance-compact-viewport-390-footnotes.png').write_bytes(png(288)) - self.assertEqual(run('generate', self.root), 49) + self.assertEqual(run('generate', self.root), 56) def test_all_responsive_full_pages_enforce_real_viewport_width(self): for width in (320, 375, 390, 412, 768, 1440, 1920): with self.subTest(width=width): name = self.root / f'viewport-{width}-page.png' + original = name.read_bytes() name.write_bytes(png(width)) - self.assertEqual(run('generate', self.root), 49) + self.assertEqual(run('generate', self.root), 55) name.write_bytes(png(width - 1)) with self.assertRaisesRegex(ValueError, 'width mismatch'): run('generate', self.root) - name.unlink() + name.write_bytes(original) def test_non_fullpage_crops_keep_their_actual_width(self): for name, width in ( @@ -103,9 +108,34 @@ def test_non_fullpage_crops_keep_their_actual_width(self): with self.subTest(name=name): path = self.root / name path.write_bytes(png(width)) - self.assertEqual(run('generate', self.root), 49) + self.assertEqual(run('generate', self.root), 56) path.unlink() + def test_rejects_missing_appearance_mode_even_if_count_is_unchanged(self): + victim = self.root / 'appearance-spacious-viewport-1440-photography-lightbox.png' + victim.unlink() + (self.root / 'unrelated-extra.png').write_bytes(png(390)) + with self.assertRaisesRegex(ValueError, 'Missing required visual coverage.*spacious'): + run('generate', self.root) + + def test_rejects_missing_responsive_viewport_even_if_count_is_unchanged(self): + (self.root / 'viewport-320-page.png').unlink() + (self.root / 'unrelated-extra.png').write_bytes(png(390)) + with self.assertRaisesRegex(ValueError, 'Missing required visual coverage.*viewport-320-page'): + run('generate', self.root) + + def test_missing_visual_coverage_invalidates_previous_inventory(self): + run('generate', self.root) + (self.root / 'viewport-768-page.png').unlink() + with self.assertRaisesRegex(ValueError, 'visual coverage|Insufficient screenshot'): + run('generate', self.root) + self.assertFalse((self.root / MANIFEST).exists()) + + def test_required_coverage_is_independent_of_extra_element_crops(self): + (self.root / 'gutenberg-viewport-1440-portfolio-first.png').write_bytes(png(300)) + self.assertEqual(run('generate', self.root), 56) + self.assertEqual(run('verify', self.root), 56) + def test_rejects_legacy_hash_mismatch(self): data = json.loads((self.root / 'VISUAL_EVIDENCE.json').read_text()) data['files'][0]['sha256'] = '0' * 64 diff --git a/tests/verify_browser_inventory.py b/tests/verify_browser_inventory.py index 69c6506f..1eabcc37 100644 --- a/tests/verify_browser_inventory.py +++ b/tests/verify_browser_inventory.py @@ -23,6 +23,29 @@ ) NAME = re.compile(r'[a-zA-Z0-9][a-zA-Z0-9_.-]*\.png\Z') +# A raw screenshot count cannot prove that all Appearance modes or viewport +# families were captured. Extra PNGs must never satisfy this coverage gate. +REQUIRED_APPEARANCE = frozenset( + f'appearance-{profile}-viewport-{width}-{surface}.png' + for profile in ('compact', 'default', 'spacious') + for width in (390, 1440) + for surface in ( + 'portfolio', 'photography', 'knowledge', 'post', + 'home-controls', 'search-controls', 'page', 'photography-lightbox', + ) +) +REQUIRED_RESPONSIVE = frozenset( + f'viewport-{width}-page.png' + for width in (320, 375, 390, 412, 768, 1440, 1920) +) +REQUIRED_SCREENSHOTS = REQUIRED_APPEARANCE | REQUIRED_RESPONSIVE + + +def check_coverage(records): + missing = REQUIRED_SCREENSHOTS.difference(records) + if missing: + raise ValueError('Missing required visual coverage: ' + ', '.join(sorted(missing))) + def scan(root): reject_linked_ancestors(root) @@ -56,8 +79,9 @@ def scan(root): width=actual_width, height=height, bytes=len(data)) if len(records) > 5000: raise ValueError('Too many screenshot files') - if len(records) < 48: + if len(records) < len(REQUIRED_SCREENSHOTS): raise ValueError('Insufficient screenshot evidence') + check_coverage(records) return dict(sorted(records.items())) From 20adfed5fc31592793d32dcd79d3929d81f63178 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 09:14:54 +0900 Subject: [PATCH 29/33] fix(ci): fail fast on mixed fatal WordPress download errors --- bin/retry-wp-core-download.sh | 8 ++++++- tests/test_retry_wp_core_download.py | 34 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/bin/retry-wp-core-download.sh b/bin/retry-wp-core-download.sh index 718b4794..83370696 100644 --- a/bin/retry-wp-core-download.sh +++ b/bin/retry-wp-core-download.sh @@ -13,7 +13,13 @@ for attempt in 1 2 3; do exit 0 fi cat "$log_file" >&2 - if ! grep -Eiq 'cURL error (6|7|28)|[Rr]esolv(ing|e) timed out|[Cc]ould not resolve host|[Tt]emporary failure in name resolution|[Cc]onnection timed out' "$log_file"; then + # Fatal validation or configuration errors take precedence over transient + # network messages. Never retry a mixed-error response. + if grep -Eiq 'checksum mismatch|signature mismatch|SSL certificate problem|permission denied|fatal error|invalid (version|option|argument)|HTTP (4[0-9]{2}|5[0-9]{2})|(^|[[:space:]])(403 forbidden|404 not found)' "$log_file"; then + echo 'WordPress download has a fatal validation/configuration error; not retrying.' >&2 + exit 1 + fi + if ! grep -Eiq 'cURL error (6|7|28)([^0-9]|$)|[Rr]esolv(ing|e) timed out|[Cc]ould not resolve host|[Tt]emporary failure in name resolution|[Cc]onnection timed out' "$log_file"; then echo 'WordPress download failed for a non-transient reason; not retrying.' >&2 exit 1 fi diff --git a/tests/test_retry_wp_core_download.py b/tests/test_retry_wp_core_download.py index eda619dd..3c0cb3c9 100644 --- a/tests/test_retry_wp_core_download.py +++ b/tests/test_retry_wp_core_download.py @@ -12,13 +12,13 @@ class WordPressDownloadRetryTests(unittest.TestCase): def run_case(self, errors, expected_status): with tempfile.TemporaryDirectory() as folder: root = Path(folder) - (root / 'errors').write_text('\n'.join(errors) + '\n') + (root / 'errors').write_text('\n'.join(message.replace('\n', r'\n') for message in errors) + '\n') (root / 'wp').write_text('''#!/bin/sh printf '%s\\n' "$*" >> "$TEST_LOG" count=$(wc -l < "$TEST_LOG") message=$(sed -n "${count}p" "$TEST_ERRORS") if [ "$message" = 'SUCCESS' ]; then exit 0; fi -printf '%s\\n' "$message" >&2 +printf '%b\\n' "$message" >&2 exit 1 ''') (root / 'sleep').write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$TEST_SLEEP"\n') @@ -56,5 +56,35 @@ def test_http_404_is_not_hidden(self): self.assertEqual((len(calls), sleeps), (1, [])) + def test_mixed_transient_and_fatal_checksum_is_not_retried(self): + calls, sleeps, result = self.run_case(['cURL error 28: timeout\\nChecksum mismatch'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + self.assertIn('fatal validation/configuration', result.stderr) + + def test_mixed_transient_and_fatal_certificate_is_not_retried(self): + calls, sleeps, _ = self.run_case(['cURL error 6: Could not resolve host\\nSSL certificate problem'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + + def test_unrecognized_curl_error_is_not_retried(self): + calls, sleeps, _ = self.run_case(['cURL error 280: Unknown failure'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + + def test_two_transient_errors_then_success(self): + calls, sleeps, _ = self.run_case(['cURL error 7: Connection timed out', + 'cURL error 28: Resolving timed out', 'SUCCESS'], 0) + self.assertEqual((len(calls), sleeps), (3, ['5', '10'])) + + def test_mixed_transient_and_invalid_version_is_not_retried(self): + calls, sleeps, _ = self.run_case(['cURL error 28: timeout\\nInvalid version'], 1) + self.assertEqual((len(calls), sleeps), (1, [])) + + def test_no_arguments_returns_usage_without_network(self): + with tempfile.TemporaryDirectory() as folder: + result = subprocess.run(['bash', str(SCRIPT)], capture_output=True, text=True, + env={**os.environ, 'TMPDIR': folder}, timeout=5) + self.assertEqual(result.returncode, 2) + self.assertIn('Usage:', result.stderr) + + if __name__ == '__main__': unittest.main() From 3d17e96c3f0edf5ea496c6e35771bc15704d0a2e Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 10:11:24 +0900 Subject: [PATCH 30/33] fix(ci): use bounded WordPress Core downloads across all fixtures --- .github/workflows/photography.yml | 2 +- .github/workflows/quality.yml | 8 ++--- ...test_wp_core_download_workflow_contract.py | 33 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 tests/test_wp_core_download_workflow_contract.py diff --git a/.github/workflows/photography.yml b/.github/workflows/photography.yml index 807742d1..6bb18ea0 100644 --- a/.github/workflows/photography.yml +++ b/.github/workflows/photography.yml @@ -50,7 +50,7 @@ jobs: curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /tmp/wp chmod +x /tmp/wp && sudo mv /tmp/wp /usr/local/bin/wp mkdir -p /tmp/wp-photo - wp core download --path=/tmp/wp-photo --version=latest + bash bin/retry-wp-core-download.sh --path=/tmp/wp-photo --version=latest wp config create --path=/tmp/wp-photo --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306 --skip-check wp core install --path=/tmp/wp-photo --url=http://127.0.0.1:8080 --title=Slateframe --admin_user=admin --admin_password=ci-password --admin_email=ci@example.com --skip-email ln -s "$GITHUB_WORKSPACE" /tmp/wp-photo/wp-content/themes/slateframe diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index be6894e4..4b7cae1d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -224,7 +224,7 @@ jobs: set -euo pipefail site="/tmp/wp-compat-${{ matrix.wordpress }}-${{ matrix.php }}" mkdir -p "$site" - wp core download --path="$site" --version="${{ matrix.wordpress }}" + bash bin/retry-wp-core-download.sh --path="$site" --version="${{ matrix.wordpress }}" wp config create --path="$site" --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306 --skip-check wp core install --path="$site" --url=http://127.0.0.1:8080 --title=Slateframe --admin_user=admin --admin_password=ci-password --admin_email=ci@example.com --skip-email ln -s "$GITHUB_WORKSPACE" "$site/wp-content/themes/slateframe" @@ -265,7 +265,7 @@ jobs: curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /tmp/wp chmod +x /tmp/wp && sudo mv /tmp/wp /usr/local/bin/wp mkdir -p /tmp/wp-theme-check - wp core download --path=/tmp/wp-theme-check --version=latest + bash bin/retry-wp-core-download.sh --path=/tmp/wp-theme-check --version=latest wp config create --path=/tmp/wp-theme-check --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306 --skip-check wp config set WP_DEBUG true --raw --path=/tmp/wp-theme-check wp core install --path=/tmp/wp-theme-check --url=http://127.0.0.1:8080 --title=Slateframe --admin_user=admin --admin_password=ci-password --admin_email=ci@example.com --skip-email @@ -331,7 +331,7 @@ jobs: - name: Install WordPress and activate Slateframe run: | mkdir -p /tmp/wp-site - wp core download --path=/tmp/wp-site --version=latest + bash bin/retry-wp-core-download.sh --path=/tmp/wp-site --version=latest wp config create --path=/tmp/wp-site --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306 --skip-check wp core install --path=/tmp/wp-site --url=http://127.0.0.1:8080 --title=Slateframe --admin_user=admin --admin_password=ci-password --admin_email=ci@example.com --skip-email ln -s "$GITHUB_WORKSPACE" /tmp/wp-site/wp-content/themes/slateframe @@ -445,7 +445,7 @@ jobs: set -euo pipefail curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /tmp/wp && chmod +x /tmp/wp && sudo mv /tmp/wp /usr/local/bin/wp mkdir -p /tmp/wp-browser - wp core download --path=/tmp/wp-browser --version=latest + bash bin/retry-wp-core-download.sh --path=/tmp/wp-browser --version=latest wp config create --path=/tmp/wp-browser --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306 --skip-check wp core install --path=/tmp/wp-browser --url=http://127.0.0.1:8080 --title='Slateframe' --admin_user=admin --admin_password=ci-password --admin_email=ci@example.com --skip-email wp post delete 1 --force --path=/tmp/wp-browser >/dev/null diff --git a/tests/test_wp_core_download_workflow_contract.py b/tests/test_wp_core_download_workflow_contract.py new file mode 100644 index 00000000..f0e3f233 --- /dev/null +++ b/tests/test_wp_core_download_workflow_contract.py @@ -0,0 +1,33 @@ +"""Verify every WordPress Core CI download uses the existing bounded retry wrapper.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = (ROOT / '.github/workflows/quality.yml', ROOT / '.github/workflows/photography.yml') +RETRY = 'bash bin/retry-wp-core-download.sh' + +class WordPressCoreDownloadWorkflowContract(unittest.TestCase): + def test_all_five_core_installs_use_retry(self): + total = 0 + for path in WORKFLOWS: + source = path.read_text(encoding='utf-8') + with self.subTest(path=path.name): + self.assertNotRegex(source, r'(?m)^\s*wp core download\s') + total += source.count(RETRY) + self.assertEqual(total, 5) + + def test_matrix_is_preserved(self): + quality = WORKFLOWS[0].read_text(encoding='utf-8') + self.assertIn('bash bin/retry-wp-core-download.sh --path="$site" --version="${{ matrix.wordpress }}"', quality) + self.assertIn("- wordpress: '6.7'", quality) + self.assertIn("- wordpress: 'latest'", quality) + + def test_no_silenced_failures(self): + for path in WORKFLOWS: + self.assertNotRegex(path.read_text(encoding='utf-8'), r'(?m)^\s*bash bin/retry-wp-core-download\.sh[^\n]*(?:\|\|\s*true|continue-on-error)') + wrapper = (ROOT / 'bin/retry-wp-core-download.sh').read_text(encoding='utf-8') + self.assertIn('set -euo pipefail', wrapper) + self.assertIn('after three bounded attempts', wrapper) + +if __name__ == '__main__': + unittest.main() From a6477b3c14dba23b9c44eae6e4f4a42a4b0a9cba Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 10:21:29 +0900 Subject: [PATCH 31/33] fix(ci): authenticate Gutenberg through guarded WordPress Core requests --- .github/workflows/quality.yml | 2 + docs/GUTENBERG-LOGIN-CI-ISOLATION.md | 6 ++ tests/browser/support/editor-login.js | 61 +++++++------ tests/browser/support/wordpress-login.js | 37 ++++++++ tests/browser/test-editor-login-helper.cjs | 96 +++++++++++++++++++++ tests/browser/test-wordpress-login.cjs | 35 ++++++++ tests/test_editor_login_request_contract.py | 37 ++++++++ 7 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 tests/browser/support/wordpress-login.js create mode 100644 tests/browser/test-editor-login-helper.cjs create mode 100644 tests/browser/test-wordpress-login.cjs create mode 100644 tests/test_editor_login_request_contract.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4b7cae1d..5796b1d3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -38,6 +38,8 @@ jobs: find tests/browser -type f -name '*.js' -print0 | xargs -0 -n1 node --check node --check tests/browser/test-visual-evidence-helper.cjs node tests/browser/test-visual-evidence-helper.cjs + node tests/browser/test-wordpress-login.cjs + node tests/browser/test-editor-login-helper.cjs - name: Validate build script syntax run: bash -n bin/build-theme-zip.sh - name: Validate screenshot PNG and corruption regression diff --git a/docs/GUTENBERG-LOGIN-CI-ISOLATION.md b/docs/GUTENBERG-LOGIN-CI-ISOLATION.md index 0a893817..72fae06b 100644 --- a/docs/GUTENBERG-LOGIN-CI-ISOLATION.md +++ b/docs/GUTENBERG-LOGIN-CI-ISOLATION.md @@ -5,3 +5,9 @@ Real WordPress Gutenberg acceptance tests perform login POSTs and create session Intermittent `page.waitForResponse` timeouts occurred at the same login helper on 1440px (run 39027486576) and 390px (run 39045981518). A successful intervening run does not prove the underlying contention disappeared. The Browser workflow now has three disjoint groups: parallel read-only specs, serial WP-CLI fixture writers, and serial authenticated Gutenberg specs. All existing tests and 30-second timeouts remain; no skips, retries, thresholds, or assertions are relaxed. If a login still times out, inspect the Playwright trace and WordPress server log rather than raising the timeout. This changes CI scheduling only; no theme runtime code or production site is affected. + +## Stable Core login acceptance + +The isolated Gutenberg specs still test the real WordPress editor with the existing 30-second test timeout. The shared helper first loads Core's login form (and its test cookie), then uses the Playwright page context's shared API request to POST genuine credentials to the **loopback fixture only**. It rejects unexpected login-page redirects, failed HTTP responses, redirects outside the same WordPress installation, absent signed login cookies, unexpected editor IDs and missing Gutenberg blocks. WordPress subdirectory fixtures are supported. No synthetic authentication cookies, skipped tests, extra retries or relaxed assertions are introduced. + +Node regressions for login transport, redirect and cookie safety run in Static quality. Exact-HEAD real WordPress Browser smoke remains the required acceptance gate. diff --git a/tests/browser/support/editor-login.js b/tests/browser/support/editor-login.js index a71eeacd..540a085f 100644 --- a/tests/browser/support/editor-login.js +++ b/tests/browser/support/editor-login.js @@ -1,5 +1,6 @@ /** Shared authentication boundary for real WordPress Gutenberg acceptance tests. */ const { expect } = require('@playwright/test'); +const { buildLoginForm, validateLoginRedirect, hasWordPressSession } = require('./wordpress-login'); async function openAuthenticatedEditor(page, postId) { const id = String(postId); @@ -7,47 +8,45 @@ async function openAuthenticatedEditor(page, postId) { throw new Error('The Gutenberg fixture requires a positive integer post ID.'); } - await page.goto('/wp-login.php', { waitUntil: 'domcontentloaded' }); + // Never transmit the fixed CI credentials outside the loopback fixture. + // Preserve WordPress installations mounted under a path prefix. + const fixtureBase = new URL(process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://127.0.0.1:8080'); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(fixtureBase.hostname) || + !['http:', 'https:'].includes(fixtureBase.protocol) || fixtureBase.username || fixtureBase.password) { + throw new Error('Gutenberg authentication requires a loopback WordPress fixture.'); + } + const basePath = fixtureBase.pathname.endsWith('/') ? fixtureBase.pathname : fixtureBase.pathname + '/'; + const expectedLoginURL = new URL(basePath + 'wp-login.php', fixtureBase.origin); + await page.goto(expectedLoginURL.href, { waitUntil: 'domcontentloaded' }); const loginURL = page.url(); const loginAddress = new URL(loginURL); - const origin = loginAddress.origin; - const loginPath = loginAddress.pathname; - if (!loginPath.endsWith('/wp-login.php')) { - throw new Error('Unexpected WordPress login endpoint.'); + if (loginAddress.origin !== expectedLoginURL.origin || + loginAddress.pathname !== expectedLoginURL.pathname) { + throw new Error('Unexpected WordPress login endpoint for the configured fixture.'); } - const editorPath = loginPath.slice(0, -'/wp-login.php'.length) + '/wp-admin/post.php'; - await page.locator('#user_login').fill('admin'); - await page.locator('#user_pass').fill('ci-password'); + const editorPath = loginAddress.pathname.slice(0, -'/wp-login.php'.length) + '/wp-admin/post.php'; + const editorURL = loginAddress.origin + editorPath + '?post=' + encodeURIComponent(id) + '&action=edit'; + await expect(page.locator('#loginform')).toBeVisible(); - // Observe the actual login POST, not an intermediate dashboard load event. - const loginResponse = page.waitForResponse((response) => { - const url = new URL(response.url()); - return url.origin === origin && url.pathname === loginPath - && response.request().method() === 'POST'; + // Perform real Core authentication through the browser context's shared + // cookie jar. Do not depend on a navigation POST event from clicking Submit. + const response = await page.request.post(loginURL, { + form: buildLoginForm(loginURL, editorURL), + maxRedirects: 0, }); - const [response] = await Promise.all([ - loginResponse, - page.locator('#wp-submit').click({ noWaitAfter: true }), - ]); - if (response.status() >= 400) { - throw new Error(`WordPress login POST returned HTTP ${response.status()}.`); - } - - // Failed credentials can return an HTTP 200 response. Require a real session. + validateLoginRedirect(response, loginURL, editorURL); await expect.poll(async () => { const cookies = await page.context().cookies(loginURL); - return cookies.some((cookie) => cookie.name.startsWith('wordpress_logged_in_') && cookie.value); + return hasWordPressSession(cookies); }).toBe(true); - await page.goto(`${editorPath}?post=${encodeURIComponent(id)}&action=edit`, { - waitUntil: 'domcontentloaded', - }); + await page.goto(editorURL, { waitUntil: 'domcontentloaded' }); await expect(page).toHaveURL(/\/wp-admin\/post\.php\?/); - const editorURL = new URL(page.url()); - expect(editorURL.origin).toBe(origin); - expect(editorURL.pathname).toBe(editorPath); - expect(editorURL.searchParams.get('post')).toBe(id); - expect(editorURL.searchParams.get('action')).toBe('edit'); + const actual = new URL(page.url()); + expect(actual.origin).toBe(loginAddress.origin); + expect(actual.pathname).toBe(editorPath); + expect(actual.searchParams.get('post')).toBe(id); + expect(actual.searchParams.get('action')).toBe('edit'); await page.waitForFunction(() => { const select = window.wp?.data?.select?.('core/block-editor'); return Boolean(select?.getBlocks?.().length); diff --git a/tests/browser/support/wordpress-login.js b/tests/browser/support/wordpress-login.js new file mode 100644 index 00000000..2aeefb40 --- /dev/null +++ b/tests/browser/support/wordpress-login.js @@ -0,0 +1,37 @@ +/** WordPress Core login request contract for Gutenberg acceptance tests. */ +function buildLoginForm(loginURL, editorURL) { + const login = new URL(loginURL); + const editor = new URL(editorURL); + if (login.origin !== editor.origin || !login.pathname.endsWith('/wp-login.php') || + !editor.pathname.startsWith(login.pathname.slice(0, -'wp-login.php'.length) + 'wp-admin/')) { + throw new Error('The Gutenberg login target must be the same WordPress installation.'); + } + return { + log: 'admin', + pwd: 'ci-password', + 'wp-submit': 'Log In', + redirect_to: editor.href, + testcookie: '1', + }; +} + +function validateLoginRedirect(response, loginURL, editorURL) { + const status = response.status(); + if (status !== 302 && status !== 303) { + throw new Error(`WordPress login did not redirect after authentication (HTTP ${status}).`); + } + const location = response.headers()['location']; + if (!location) throw new Error('WordPress login redirect did not provide a Location header.'); + const redirect = new URL(location, loginURL); + const editor = new URL(editorURL); + const adminPath = editor.pathname.slice(0, editor.pathname.indexOf('/wp-admin/') + '/wp-admin/'.length); + if (redirect.origin !== editor.origin || !redirect.pathname.startsWith(adminPath)) { + throw new Error('WordPress login redirected outside its authenticated admin area.'); + } +} + +function hasWordPressSession(cookies) { + return cookies.some((cookie) => cookie.name.startsWith('wordpress_logged_in_') && Boolean(cookie.value)); +} + +module.exports = { buildLoginForm, validateLoginRedirect, hasWordPressSession }; diff --git a/tests/browser/test-editor-login-helper.cjs b/tests/browser/test-editor-login-helper.cjs new file mode 100644 index 00000000..9d253074 --- /dev/null +++ b/tests/browser/test-editor-login-helper.cjs @@ -0,0 +1,96 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const load = Module._load; +function expect(value) { + return { + async toBeVisible() { assert.ok(value); }, + async toHaveURL(pattern) { assert.match(value.url(), pattern); }, + toBe(target) { assert.equal(value, target); }, + }; +} +expect.poll = (read) => ({ async toBe(target) { assert.equal(await read(), target); } }); +Module._load = function(name, ...args) { + if (name === '@playwright/test') return { expect }; + return load.call(this, name, ...args); +}; +const { openAuthenticatedEditor } = require('./support/editor-login'); +Module._load = load; +function fixture(options={}) { + const calls=[]; let current='http://127.0.0.1:8080/'; + const page={ + url:()=>current, + async goto(target) { + calls.push(['goto',target]); + current=target.endsWith('/wp-login.php') ? (options.loginLanding || target) : (options.editorLanding || target); + }, + locator:()=>({ visible:true }), + request:{ async post(url,config) { + calls.push(['post',url,config]); + return {status:()=>options.status ?? 302,headers:()=>({location:options.location ?? config.form.redirect_to})}; + }}, + context:()=>({cookies:async()=>options.cookies ?? [{name:'wordpress_logged_in_ci',value:'signed'}]}), + async waitForFunction() { if(options.noBlocks) throw new Error('No Gutenberg blocks'); } + }; + return {page,calls}; +} +test('authenticate via real Core request and navigate Gutenberg editor',async()=>{ + const {page,calls}=fixture(); await openAuthenticatedEditor(page,5); + assert.equal(calls[0][1],'http://127.0.0.1:8080/wp-login.php'); + const post=calls.find(([type])=>type==='post'); + assert.equal(post[2].maxRedirects,0); + assert.equal(post[2].form.testcookie,'1'); + assert.equal(post[2].form.redirect_to,'http://127.0.0.1:8080/wp-admin/post.php?post=5&action=edit'); +}); +test('reject invalid post ID before network',async()=>{ + const {page,calls}=fixture(); + await assert.rejects(openAuthenticatedEditor(page,'5&action=delete'),/positive integer/); + assert.equal(calls.length,0); +}); +test('reject HTTP 200 WordPress login error',async()=>await assert.rejects(openAuthenticatedEditor(fixture({status:200}).page,5),/HTTP 200/)); +test('reject missing signed session',async()=>await assert.rejects(openAuthenticatedEditor(fixture({cookies:[]}).page,5),/false !== true/)); +test('reject external response redirect',async()=>await assert.rejects(openAuthenticatedEditor(fixture({location:'https://invalid.example/wp-admin/'}).page,5),/outside/)); +test('reject malicious external login landing before POST',async()=>{ + const {page,calls}=fixture({loginLanding:'https://invalid.example/wp-login.php'}); + await assert.rejects(openAuthenticatedEditor(page,5),/Unexpected WordPress login endpoint/); + assert.equal(calls.filter(([type])=>type==='post').length,0); +}); +test('reject same-origin shadow login before POST',async()=>{ + const {page,calls}=fixture({loginLanding:'http://127.0.0.1:8080/shadow/wp-login.php'}); + await assert.rejects(openAuthenticatedEditor(page,5),/Unexpected WordPress login endpoint/); + assert.equal(calls.filter(([type])=>type==='post').length,0); +}); +test('reject public fixture before navigation',async()=>{ + const saved=process.env.PLAYWRIGHT_TEST_BASE_URL; + process.env.PLAYWRIGHT_TEST_BASE_URL='https://example.com/'; + try { + const {page,calls}=fixture(); + await assert.rejects(openAuthenticatedEditor(page,5),/loopback WordPress fixture/); + assert.equal(calls.length,0); + } finally { + if(saved===undefined) delete process.env.PLAYWRIGHT_TEST_BASE_URL; + else process.env.PLAYWRIGHT_TEST_BASE_URL=saved; + } +}); +test('accept configured WordPress subdirectory fixture',async()=>{ + const saved=process.env.PLAYWRIGHT_TEST_BASE_URL; + process.env.PLAYWRIGHT_TEST_BASE_URL='http://127.0.0.1:8080/blog'; + try { + const {page,calls}=fixture({ + loginLanding:'http://127.0.0.1:8080/blog/wp-login.php', + location:'http://127.0.0.1:8080/blog/wp-admin/', + editorLanding:'http://127.0.0.1:8080/blog/wp-admin/post.php?post=5&action=edit' + }); + await openAuthenticatedEditor(page,5); + assert.equal(calls[0][1],'http://127.0.0.1:8080/blog/wp-login.php'); + } finally { + if(saved===undefined) delete process.env.PLAYWRIGHT_TEST_BASE_URL; + else process.env.PLAYWRIGHT_TEST_BASE_URL=saved; + } +}); +test('reject editor redirected to different post',async()=>{ + await assert.rejects(openAuthenticatedEditor(fixture({editorLanding:'http://127.0.0.1:8080/wp-admin/post.php?post=7&action=edit'}).page,5)); +}); +test('propagate missing Gutenberg block state',async()=>{ + await assert.rejects(openAuthenticatedEditor(fixture({noBlocks:true}).page,5),/No Gutenberg blocks/); +}); diff --git a/tests/browser/test-wordpress-login.cjs b/tests/browser/test-wordpress-login.cjs new file mode 100644 index 00000000..eaf2d1b2 --- /dev/null +++ b/tests/browser/test-wordpress-login.cjs @@ -0,0 +1,35 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildLoginForm, validateLoginRedirect, hasWordPressSession } = require('./support/wordpress-login'); +const login = 'http://127.0.0.1:8080/wp-login.php'; +const editor = 'http://127.0.0.1:8080/wp-admin/post.php?post=5&action=edit'; +const response = (status, location) => ({ status: () => status, headers: () => location === undefined ? {} : { location } }); +test('Core form sends credentials only to WordPress login', () => { + assert.deepEqual(buildLoginForm(login, editor), { + log: 'admin', pwd: 'ci-password', 'wp-submit': 'Log In', redirect_to: editor, testcookie: '1', + }); +}); +test('reject login outside Core', () => assert.throws(() => buildLoginForm(login.replace('/wp-login.php', '/login'), editor), /same WordPress/)); +test('reject cross-origin admin target', () => assert.throws(() => buildLoginForm(login, 'https://invalid.example/wp-admin/post.php'), /same WordPress/)); +test('reject non-admin target', () => assert.throws(() => buildLoginForm(login, 'http://127.0.0.1:8080/'), /same WordPress/)); +test('accept 302 Core editor redirect', () => assert.doesNotThrow(() => validateLoginRedirect(response(302, editor), login, editor))); +test('accept 303 relative admin redirect', () => assert.doesNotThrow(() => validateLoginRedirect(response(303, '/wp-admin/'), login, editor))); +test('reject 200 failed login even if it contains a form', () => assert.throws(() => validateLoginRedirect(response(200, editor), login, editor), /HTTP 200/)); +test('reject 503 error', () => assert.throws(() => validateLoginRedirect(response(503, editor), login, editor), /HTTP 503/)); +test('reject missing Location', () => assert.throws(() => validateLoginRedirect(response(302), login, editor), /Location/)); +test('reject cross-origin redirect', () => assert.throws(() => validateLoginRedirect(response(302, 'https://invalid.example/wp-admin/'), login, editor), /outside/)); +test('reject public redirect', () => assert.throws(() => validateLoginRedirect(response(302, '/'), login, editor), /outside/)); +test('reject similarly prefixed non-admin path', () => assert.throws(() => validateLoginRedirect(response(302, '/wp-admin-fake/'), login, editor), /outside/)); +test('support subdirectory admin only', () => { + const nestedLogin = 'http://127.0.0.1:8080/blog/wp-login.php'; + const nestedEditor = 'http://127.0.0.1:8080/blog/wp-admin/post.php?post=5&action=edit'; + assert.equal(buildLoginForm(nestedLogin, nestedEditor).redirect_to, nestedEditor); + assert.throws(() => validateLoginRedirect(response(302, '/wp-admin/'), nestedLogin, nestedEditor), /outside/); + assert.doesNotThrow(() => validateLoginRedirect(response(303, '/blog/wp-admin/'), nestedLogin, nestedEditor)); +}); +test('reject missing or empty signed login cookie', () => { + assert.equal(hasWordPressSession([]), false); + assert.equal(hasWordPressSession([{ name: 'wordpress_logged_in_ci', value: '' }]), false); + assert.equal(hasWordPressSession([{ name: 'wordpress_test_cookie', value: 'WP Cookie check' }]), false); +}); +test('accept nonempty WordPress signed session', () => assert.equal(hasWordPressSession([{ name: 'wordpress_logged_in_ci', value: 'signed' }]), true)); diff --git a/tests/test_editor_login_request_contract.py b/tests/test_editor_login_request_contract.py new file mode 100644 index 00000000..df5912b0 --- /dev/null +++ b/tests/test_editor_login_request_contract.py @@ -0,0 +1,37 @@ +"""Guard WordPress Core login request and CI regression test wiring.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class GutenbergLoginRequestContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.helper = (ROOT / 'tests/browser/support/editor-login.js').read_text(encoding='utf-8') + cls.workflow = (ROOT / '.github/workflows/quality.yml').read_text(encoding='utf-8') + + def test_browser_shared_cookie_post(self): + self.assertIn('page.request.post(loginURL', self.helper) + self.assertIn('maxRedirects: 0', self.helper) + self.assertIn('hasWordPressSession(cookies)', self.helper) + self.assertIn("select?.getBlocks?.().length", self.helper) + + def test_credentials_protected_by_fixture_origin_and_path(self): + self.assertIn('expectedLoginURL.pathname', self.helper) + self.assertIn("'127.0.0.1', 'localhost', '[::1]'", self.helper) + self.assertLess(self.helper.index('Unexpected WordPress login endpoint for the configured fixture.'), + self.helper.index('page.request.post(loginURL')) + + def test_regressions_are_executed_in_static_ci(self): + self.assertIn('node tests/browser/test-wordpress-login.cjs', self.workflow) + self.assertIn('node tests/browser/test-editor-login-helper.cjs', self.workflow) + + def test_no_timeout_relaxation_or_skips(self): + self.assertNotIn('waitForResponse', self.helper) + self.assertNotIn('setDefaultTimeout', self.helper) + self.assertNotIn('test.skip', self.helper) + + +if __name__ == '__main__': + unittest.main() From 6ffc2d501c0b364b4ccd67bb935de5bac085d106 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 10:25:31 +0900 Subject: [PATCH 32/33] test(ci): align Gutenberg contract with real Core request authentication --- tests/test_editor_login_contract.py | 39 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/test_editor_login_contract.py b/tests/test_editor_login_contract.py index 6f70b58e..761465af 100644 --- a/tests/test_editor_login_contract.py +++ b/tests/test_editor_login_contract.py @@ -1,9 +1,11 @@ -"""Protect the real Gutenberg login boundary without weakening browser coverage.""" +"""Protect real Gutenberg login, session, editor identity and safety boundaries.""" from pathlib import Path import unittest ROOT = Path(__file__).resolve().parents[1] HELPER = (ROOT / "tests/browser/support/editor-login.js").read_text(encoding="utf-8") +CORE = (ROOT / "tests/browser/support/wordpress-login.js").read_text(encoding="utf-8") +QUALITY = (ROOT / ".github/workflows/quality.yml").read_text(encoding="utf-8") EDITOR = (ROOT / "tests/browser/editor.spec.js").read_text(encoding="utf-8") CJK = (ROOT / "tests/browser/cjk-heading-editor.spec.js").read_text(encoding="utf-8") @@ -14,18 +16,39 @@ def test_both_real_editor_suites_share_login_boundary(self): self.assertIn("openAuthenticatedEditor(page, postId)", CJK) self.assertNotIn("page.waitForURL(/\\/wp-admin\\//)", EDITOR) - def test_login_observes_actual_post_and_session(self): - for marker in ("waitForResponse", "response.request().method() === 'POST'", - "url.pathname === loginPath", "wordpress_logged_in_", - "response.status() >= 400"): + def test_login_performs_real_wordpress_post_and_signed_session_verification(self): + # The browser form navigation event was absent in a real failing trace. + # Preserve actual WP authentication while avoiding that event race. + for marker in ("page.request.post(loginURL", "form: buildLoginForm(loginURL, editorURL)", + "maxRedirects: 0", "validateLoginRedirect(response, loginURL, editorURL)", + "page.context().cookies(loginURL)", "hasWordPressSession(cookies)"): self.assertIn(marker, HELPER) + self.assertNotIn("waitForResponse", HELPER) + for marker in ("log: 'admin'", "pwd: 'ci-password'", "testcookie: '1'", + "status !== 302 && status !== 303", "headers()['location']", + "wordpress_logged_in_"): + self.assertIn(marker, CORE) + + def test_no_credentials_leave_expected_fixture(self): + for marker in ("expectedLoginURL.origin", "expectedLoginURL.pathname", + "loopback WordPress fixture", "Unexpected WordPress login endpoint"): + self.assertIn(marker, HELPER) + self.assertLess(HELPER.index("Unexpected WordPress login endpoint for the configured fixture."), + HELPER.index("page.request.post(loginURL")) + self.assertIn("redirect.origin !== editor.origin", CORE) + self.assertIn("!redirect.pathname.startsWith(adminPath)", CORE) def test_editor_identity_and_core_store_are_checked(self): - for marker in ("Number.isSafeInteger", "editorURL.pathname", - "editorURL.origin", "searchParams.get('post')", - "searchParams.get('action')", "'core/block-editor'"): + for marker in ("Number.isSafeInteger", "actual.pathname", "actual.origin", + "searchParams.get('post')", "searchParams.get('action')", + "'core/block-editor'", "getBlocks?.().length"): self.assertIn(marker, HELPER) + def test_real_behavior_regressions_execute_in_ci(self): + self.assertIn("node tests/browser/test-wordpress-login.cjs", QUALITY) + self.assertIn("node tests/browser/test-editor-login-helper.cjs", QUALITY) + self.assertIn("npx playwright test \"${authenticated_specs[@]}\" --workers=1", QUALITY) + if __name__ == "__main__": unittest.main() From 3f03dddfa869587e2128725043f37f9864435ad0 Mon Sep 17 00:00:00 2001 From: Kexin Hao Date: Sun, 18 Oct 2026 10:45:02 +0900 Subject: [PATCH 33/33] fix(knowledge): preserve native nested list flow and forced-color markers --- assets/css/content-modes.css | 7 +- docs/KNOWLEDGE-NESTED-LISTS.md | 7 ++ tests/browser/knowledge-nested-lists.spec.js | 114 +++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 docs/KNOWLEDGE-NESTED-LISTS.md create mode 100644 tests/browser/knowledge-nested-lists.spec.js diff --git a/assets/css/content-modes.css b/assets/css/content-modes.css index bdd5a103..061189d6 100644 --- a/assets/css/content-modes.css +++ b/assets/css/content-modes.css @@ -1,7 +1,8 @@ -.wp-block-list.is-style-slateframe-steps,.is-style-slateframe-steps{counter-reset:slateframe-step;padding-inline-start:0;list-style:none}.wp-block-list.is-style-slateframe-steps>li,.is-style-slateframe-steps>li{counter-increment:slateframe-step;display:grid;grid-template-columns:2.15rem minmax(0,1fr);gap:.8rem;align-items:start;margin-block:1rem}.wp-block-list.is-style-slateframe-steps>li::before,.is-style-slateframe-steps>li::before{content:counter(slateframe-step,decimal-leading-zero);display:grid;place-items:center;min-width:2.15rem;min-height:2.15rem;border:var(--slateframe-rule);border-radius:50%;color:var(--slateframe-muted);font-size:.72rem;font-variant-numeric:tabular-nums}.wp-block-list.is-style-slateframe-checklist,.is-style-slateframe-checklist{padding-inline-start:0;list-style:none}.wp-block-list.is-style-slateframe-checklist>li,.is-style-slateframe-checklist>li{position:relative;padding-inline-start:1.65rem;margin-block:.65rem}.wp-block-list.is-style-slateframe-checklist > li::before,.is-style-slateframe-checklist > li::before{position:absolute;inset-inline-start:0;content:"✓";color:var(--slateframe-accent);font-weight:800;}.wp-block-group.is-style-slateframe-key-facts{padding-block:var(--slateframe-stack-gap);border-block:var(--slateframe-rule);}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :first-child{margin-block-start:0;}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :last-child{margin-block-end:0;}.wp-block-group.is-style-slateframe-learning-callout{padding:var(--slateframe-stack-gap);border-inline-start:3px solid var(--slateframe-accent);background:var(--slateframe-surface-soft);}.wp-block-columns.is-style-slateframe-ledger{gap:clamp(1rem,2.5vw,2rem);margin-block:0;padding-block:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-ledger:last-child{border-block-end:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief{padding-block:clamp(1.25rem,3vw,2rem);border-block:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief a{overflow-wrap:anywhere;}.slateframe-portfolio-index .wp-block-columns,.slateframe-knowledge-outline .wp-block-columns,.slateframe-project-brief-pattern .wp-block-group,.slateframe-lesson-chapter .wp-block-group{margin-block-start:clamp(1.5rem,3vw,2.25rem);}.slateframe-portfolio-index h3,.slateframe-knowledge-outline h3,.slateframe-project-brief-pattern h3,.slateframe-lesson-chapter h3{margin-block-start:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-ledger{padding-block:0.9rem;}}.wp-block-columns.is-style-slateframe-metrics{gap:0;border-block:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column{min-inline-size:0;padding:var(--slateframe-stack-gap);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column:first-child{border-inline-start:0;}.wp-block-group.is-style-slateframe-definition{padding:1rem 1.1rem;border-inline-start:3px solid var(--slateframe-text);background:var(--slateframe-surface-soft);}.wp-block-group.is-style-slateframe-definition > :first-child{margin-block-start:0;}.wp-block-group.is-style-slateframe-definition > :last-child{margin-block-end:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}}.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:clamp(1.75rem,4vw,3.25rem);border-block:var(--slateframe-rule);}.slateframe-project-meta{display:grid;gap:var(--slateframe-component-gap);margin:0;padding:0;list-style:none;}.slateframe-project-meta li{padding-block-end:var(--slateframe-caption-gap);border-block-end:var(--slateframe-rule);}.slateframe-project-meta strong{display:block;margin-block-end:0.12rem;font-size:0.76rem;letter-spacing:0.04em;text-transform:uppercase;}.slateframe-project-sections{margin-block-start:clamp(1.75rem,4vw,3rem);}.slateframe-project-sections h3,.slateframe-learning-step h3{margin-block-start:0;}.slateframe-learning-step{min-height:100%;padding-block-start:var(--slateframe-component-gap);border-block-start:2px solid var(--slateframe-text);}.slateframe-learning-step > :last-child{margin-block-end:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint{gap:0;margin-block:var(--slateframe-heading-gap);border-block:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column{min-inline-size:0;padding:clamp(1rem,2.5vw,1.5rem);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column:first-child{border-inline-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint h3{margin-block-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint .is-style-slateframe-checklist{margin-block-end:0;}.slateframe-knowledge-checklist > .wp-block-quote{margin-block-start:clamp(1.5rem,3vw,2.25rem);}@media (max-width:781px){.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:1.5rem;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}} -/* No synthetic media: balance Core Portfolio cards without featured images. */ +.is-style-slateframe-steps{counter-reset:slateframe-step;padding-inline-start:0;list-style:none}.is-style-slateframe-steps>li{counter-increment:slateframe-step;position:relative;min-inline-size:0;min-block-size:2.15rem;padding-inline-start:calc(2.15rem + var(--slateframe-component-gap));margin-block:var(--slateframe-stack-gap)}.is-style-slateframe-steps>li::before{content:counter(slateframe-step,decimal-leading-zero);position:absolute;inset-inline-start:0;inset-block-start:0;display:grid;place-items:center;inline-size:2.15rem;block-size:2.15rem;border:var(--slateframe-rule);border-radius:50%;color:var(--slateframe-muted);font-size:.72rem;font-variant-numeric:tabular-nums;direction:ltr}.is-style-slateframe-steps>li>:first-child{margin-block-start:0}.is-style-slateframe-checklist{padding-inline-start:0;list-style:none}.wp-block-list.is-style-slateframe-checklist>li,.is-style-slateframe-checklist>li{position:relative;padding-inline-start:1.65rem;margin-block:.65rem}.wp-block-list.is-style-slateframe-checklist > li::before,.is-style-slateframe-checklist > li::before{position:absolute;inset-inline-start:0;content:"✓";color:var(--slateframe-accent);font-weight:800;}.wp-block-group.is-style-slateframe-key-facts{padding-block:var(--slateframe-stack-gap);border-block:var(--slateframe-rule);}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :first-child{margin-block-start:0;}.wp-block-group:is(.is-style-slateframe-key-facts,.is-style-slateframe-learning-callout,.is-style-slateframe-project-brief) > :last-child{margin-block-end:0;}.wp-block-group.is-style-slateframe-learning-callout{padding:var(--slateframe-stack-gap);border-inline-start:3px solid var(--slateframe-accent);background:var(--slateframe-surface-soft);}.wp-block-columns.is-style-slateframe-ledger{gap:clamp(1rem,2.5vw,2rem);margin-block:0;padding-block:var(--slateframe-stack-gap);border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-ledger:last-child{border-block-end:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief{padding-block:clamp(1.25rem,3vw,2rem);border-block:var(--slateframe-rule);}.wp-block-group.is-style-slateframe-project-brief a{overflow-wrap:anywhere;}.slateframe-portfolio-index .wp-block-columns,.slateframe-knowledge-outline .wp-block-columns,.slateframe-project-brief-pattern .wp-block-group,.slateframe-lesson-chapter .wp-block-group{margin-block-start:clamp(1.5rem,3vw,2.25rem);}.slateframe-portfolio-index h3,.slateframe-knowledge-outline h3,.slateframe-project-brief-pattern h3,.slateframe-lesson-chapter h3{margin-block-start:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-ledger{padding-block:0.9rem;}}.wp-block-columns.is-style-slateframe-metrics{gap:0;border-block:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column{min-inline-size:0;padding:var(--slateframe-stack-gap);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.wp-block-columns.is-style-slateframe-metrics > .wp-block-column:first-child{border-inline-start:0;}.wp-block-group.is-style-slateframe-definition{padding:1rem 1.1rem;border-inline-start:3px solid var(--slateframe-text);background:var(--slateframe-surface-soft);}.wp-block-group.is-style-slateframe-definition > :first-child{margin-block-start:0;}.wp-block-group.is-style-slateframe-definition > :last-child{margin-block-end:0;}@media (max-width:781px){.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.wp-block-columns.is-style-slateframe-metrics:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}}.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:clamp(1.75rem,4vw,3.25rem);border-block:var(--slateframe-rule);}.slateframe-project-meta{display:grid;gap:var(--slateframe-component-gap);margin:0;padding:0;list-style:none;}.slateframe-project-meta li{padding-block-end:var(--slateframe-caption-gap);border-block-end:var(--slateframe-rule);}.slateframe-project-meta strong{display:block;margin-block-end:0.12rem;font-size:0.76rem;letter-spacing:0.04em;text-transform:uppercase;}.slateframe-project-sections{margin-block-start:clamp(1.75rem,4vw,3rem);}.slateframe-project-sections h3,.slateframe-learning-step h3{margin-block-start:0;}.slateframe-learning-step{min-height:100%;padding-block-start:var(--slateframe-component-gap);border-block-start:2px solid var(--slateframe-text);}.slateframe-learning-step > :last-child{margin-block-end:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint{gap:0;margin-block:var(--slateframe-heading-gap);border-block:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column{min-inline-size:0;padding:clamp(1rem,2.5vw,1.5rem);border-inline-start:var(--slateframe-rule);overflow-wrap:anywhere;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint > .wp-block-column:first-child{border-inline-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint h3{margin-block-start:0;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint .is-style-slateframe-checklist{margin-block-end:0;}.slateframe-knowledge-checklist > .wp-block-quote{margin-block-start:clamp(1.5rem,3vw,2.25rem);}@media (max-width:781px){.wp-block-group.is-style-slateframe-project-feature,.wp-block-group.is-style-slateframe-learning-path{padding-block:1.5rem;}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column{padding-inline:0;border-inline-start:0;border-block-start:var(--slateframe-rule);}.slateframe-knowledge-checklist .slateframe-learning-checkpoint:not(.is-not-stacked-on-mobile) > .wp-block-column:first-child{border-block-start:0;}} + :is(.slateframe-main,.editor-styles-wrapper) .slateframe-project-grid .slateframe-project-card:not(:has(.wp-block-post-featured-image img)){padding-block-start:calc(var(--slateframe-stack-gap) + var(--slateframe-media-gap));padding-inline-start:var(--slateframe-stack-gap);border-inline-start:2px solid var(--slateframe-accent)} -/* Native Query Loop titles share the Appearance control target; media focus remains visible inside cropped frames. */ + .slateframe-project-grid .wp-block-post-title a{display:block;min-block-size:var(--slateframe-control)}.slateframe-project-grid .wp-block-post-featured-image a{display:block;line-height:0}.slateframe-project-grid .wp-block-post-featured-image a:focus-visible{outline-offset:calc(-1*var(--slateframe-focus-offset))} .slateframe-project-grid .slateframe-project-card > :is(.wp-block-post-title,.wp-block-post-excerpt,.wp-block-post-date){inline-size:100%;min-inline-size:0;overflow-wrap:anywhere} +@media(forced-colors:active){.is-style-slateframe-steps>li::before{border-color:CanvasText;color:CanvasText}.is-style-slateframe-checklist>li::before{color:CanvasText}} diff --git a/docs/KNOWLEDGE-NESTED-LISTS.md b/docs/KNOWLEDGE-NESTED-LISTS.md new file mode 100644 index 00000000..3a72213f --- /dev/null +++ b/docs/KNOWLEDGE-NESTED-LISTS.md @@ -0,0 +1,7 @@ +# Core Knowledge list reflow + +Slateframe's native ordered **Steps** list must remain a WordPress Core List, not a proprietary component. An `li` with anonymous text followed by Paragraph, nested List, and another Paragraph was previously a two-column CSS grid: later block children could auto-place into the narrow badge track. This especially harmed narrow CJK/RTL reading and enlarged text. + +The badge is now positioned in the logical inline-start inset while the native list item retains ordinary block flow. Indentation consumes the existing `--slateframe-component-gap` token, the minimum block size reserves room for the badge, and the decimal counter stays LTR in RTL content. Nested Core lists retain their own markers. The first authored block has no artificial top gap. The Checklist checkmark and numbered badge use system text color under forced colors. No new JavaScript, custom block, post type, content migration, or new stylesheet request is introduced. + +The real WordPress browser regression creates and removes a temporary page, checks all seven viewport widths in both directions, verifies alignment and document containment, and captures 1440px and 390px/RTL/200%/forced-colors screenshots. It also checks the print counter and native nested markers. The contextual CSS remains within its existing 7000-byte budget; no threshold is raised. diff --git a/tests/browser/knowledge-nested-lists.spec.js b/tests/browser/knowledge-nested-lists.spec.js new file mode 100644 index 00000000..bce5d5fa --- /dev/null +++ b/tests/browser/knowledge-nested-lists.spec.js @@ -0,0 +1,114 @@ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs/promises'); +const wp = (...args) => execFileSync('wp', [...args, '--path=/tmp/wp-browser'], { encoding: 'utf8' }).trim(); + +const content = '' + + '
    ' + + '
  1. Prepare 中文 日本語 العربية — ' + 'unbroken-reference-'.repeat(8) + + '

    First explanation and a reference.

    ' + + '
    • Verify input
    • Confirm result
    ' + + '

    Continue with the verified result.

  2. ' + + '
  3. A block-first step.

    Another paragraph.

    ' + + '
    1. Nested native step
' + + '' + + '
  • Check 中文 العربية' + + '

    Evidence and result.

    • Native nested checkpoint
'; + +async function withFixture(page, run) { + const id = wp('post', 'create', '--post_type=page', '--post_status=publish', + '--post_title=Nested knowledge steps fixture', '--post_content=' + content, '--porcelain'); + try { + await page.goto('/?page_id=' + id, { waitUntil: 'networkidle' }); + const steps = page.locator('.slateframe-prose ol.is-style-slateframe-steps').first(); + await expect(steps).toBeVisible(); + await expect(steps.locator(':scope > li')).toHaveCount(2); + await run(steps); + } finally { + wp('post', 'delete', id, '--force'); + } +} + +test('native Core steps align paragraphs and nested lists at all responsive widths and directions', async ({ page }, info) => { + test.skip(info.project.use.viewport?.width !== 390, 'One isolated WordPress fixture covers the seven-width matrix'); + await withFixture(page, async steps => { + for (const width of [320, 375, 390, 412, 768, 1440, 1920]) { + await page.setViewportSize({ width, height: 900 }); + for (const dir of ['ltr', 'rtl']) { + await page.locator('html').evaluate((root, value) => { root.dir = value; }, dir); + const result = await steps.evaluate(root => { + const first = root.querySelector(':scope > li'); + const second = root.querySelector(':scope > li:nth-child(2)'); + const box = node => { const r = node.getBoundingClientRect(); return { left: r.left, right: r.right }; }; + return { + display: getComputedStyle(first).display, + position: getComputedStyle(first).position, + markerPosition: getComputedStyle(first, '::before').position, + markerDirection: getComputedStyle(first, '::before').direction, + first: [box(first.querySelector('.nested-explanation')), + box(first.querySelector('.nested-checks')), box(first.querySelector('.nested-followup'))], + second: [box(second.querySelector('.block-first')), box(second.querySelector('.nested-numbered'))], + nestedBullet: getComputedStyle(first.querySelector('.nested-checks')).listStyleType, + nestedNumber: getComputedStyle(second.querySelector('.nested-numbered')).listStyleType, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + expect(result.display, width + ' ' + dir).toBe('list-item'); + expect(result.position).toBe('relative'); + expect(result.markerPosition).toBe('absolute'); + expect(result.markerDirection).toBe('ltr'); + expect(result.nestedBullet).not.toBe('none'); + expect(result.nestedNumber).not.toBe('none'); + const edge = r => dir === 'ltr' ? r.left : r.right; + for (const group of [result.first, result.second]) { + for (const r of group.slice(1)) { + expect(Math.abs(edge(group[0]) - edge(r)), width + ' ' + dir).toBeLessThanOrEqual(1); + } + } + expect(result.overflow, width + ' ' + dir).toBeLessThanOrEqual(1); + } + } + await page.setViewportSize({ width: 1440, height: 900 }); + await page.locator('html').evaluate(root => { root.dir = 'ltr'; }); + await fs.mkdir('test-artifacts/screenshots', { recursive: true }); + await steps.screenshot({ path: 'test-artifacts/screenshots/knowledge-nested-steps-1440.png' }); + }); +}); + +test('Core step badges and checklist marks survive 200% RTL, forced colors and print', async ({ page }, info) => { + test.skip(info.project.use.viewport?.width !== 390, 'One isolated WordPress fixture'); + await withFixture(page, async steps => { + await page.setViewportSize({ width: 390, height: 900 }); + await page.locator('html').evaluate(root => { root.dir = 'rtl'; root.style.fontSize = '200%'; }); + await page.emulateMedia({ forcedColors: 'active', reducedMotion: 'reduce' }); + const result = await steps.locator(':scope > li').first().evaluate(node => { + const badge = getComputedStyle(node, '::before'); + return { + border: badge.borderTopStyle, borderWidth: parseFloat(badge.borderTopWidth), + direction: badge.direction, minHeight: parseFloat(getComputedStyle(node).minBlockSize), + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + expect(result.border).toBe('solid'); + expect(result.borderWidth).toBeGreaterThanOrEqual(1); + expect(result.direction).toBe('ltr'); + expect(result.minHeight).toBeGreaterThanOrEqual(44); + expect(result.overflow).toBeLessThanOrEqual(1); + const checklist = page.locator('.slateframe-prose .is-style-slateframe-checklist > li'); + await expect(checklist).toHaveCount(1); + const marker = await checklist.first().evaluate(node => ({ + content: getComputedStyle(node, '::before').content, + color: getComputedStyle(node, '::before').color, + nested: getComputedStyle(node.querySelector('ul')).listStyleType, + })); + expect(marker.content).not.toBe('none'); + expect(marker.color).not.toBe('transparent'); + expect(marker.nested).not.toBe('none'); + await fs.mkdir('test-artifacts/screenshots', { recursive: true }); + await steps.screenshot({ path: 'test-artifacts/screenshots/knowledge-nested-steps-390-rtl-200-forced-colors.png' }); + await page.emulateMedia({ media: 'print', forcedColors: 'none' }); + await expect(steps.locator(':scope > li')).toHaveCount(2); + expect(await steps.locator(':scope > li').first().evaluate(node => + getComputedStyle(node, '::before').content)).not.toBe('none'); + }); +});