Seven tasks: ACF fields, page-intro partial, page.php wiring, scoped CSS, page-hero partial update, Playwright tests, and a final quality gate. Co-Authored-By: Claude <noreply@anthropic.com>
24 KiB
Inner Page Layout Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the inner page layout from notes/inner-page-mockup.png so every WordPress page renders a dark hero (with background image and decorative vector on the right), a centered narrow intro section, and the standard editor body — with per-page variation only in hero image, vector, intro, and body content.
Architecture: Extend the existing Page Heading ACF field group with two new fields (hero_image, hero_vector). Modify the existing views/partials/page-hero.php to render the new image/vector and to stop rendering the intro paragraph inside the hero. Add a new views/partials/page-intro.php partial. Wire it into views/page.php between the hero and the_content. Add scoped CSS for the new hero media layers.
Tech Stack: WordPress (PHP), ACF Pro, Tailwind v4 (built via npm run build), Playwright, axe-core.
Global Constraints
- Theme root:
wp-content/themes/community-works-collaborative/ - Build command:
npm run build(Tailwind v4 →static/dist/theme.css) - Lint:
composer lint - Tests:
npx playwright test - Dev site:
http://community-works-collaborative.test/ - Existing test pattern:
tests/mobile-homepage.spec.js:6-14(getBox,expectNearhelpers;await expect(...).toBeVisible()before reading dimensions of lazy-loaded images) - The hero is loaded via
header.php:60viaget_template_part( 'views/partials/page-hero' ). Don't move that call. - ACF JSON: add the two new fields by editing
acf/group_60bfb84ae973c.json(this is the file ACF loads from). New fields keep the sameconditional_logicrule as the existingheadingandintrofields: shown whenhero_style !== 'none'. - Per-component CSS for the hero media layers goes in a new
styles/blocks/page-hero.cssand is imported fromstyles/blocks/index.css. Scoping is enforced with a.page-heroclass root. - Page slug for the dev fixture:
sample-page(percontent/basic-wp-test-content.xml:7406). Full URL:http://community-works-collaborative.test/sample-page/ - No new ACF blocks. No new dependencies. No front-page / single / archive / search / 404 changes. The mockup's bottom dark block is the existing site footer — do not add a new contact CTA to the page body.
File Structure
Create
wp-content/themes/community-works-collaborative/views/partials/page-intro.php— Renders the centered narrow intro column from the existingintroACF field. Tailwind utility classes only; no CSS file needed.wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css— Scoped styles for the new hero image/vector media layers (.page-hero__media,.page-hero__vector,.page-hero__content) including the mobile breakpoint at 767px.wp-content/themes/community-works-collaborative/tests/inner-page.spec.js— Playwright tests for the inner page layout at desktop, 402px, 320px, 767px, plus no-intro and no-asset fallback cases.
Modify
wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json— Addhero_imageandhero_vectorimage fields. Bumpmodifiedtimestamp.wp-content/themes/community-works-collaborative/styles/blocks/index.css— Add@import './page-hero.css';.wp-content/themes/community-works-collaborative/views/partials/page-hero.php— Read the two new fields. Renderhero_imageandhero_vectoras absolutely-positioned media layers. Add.page-hero,.page-hero__media,.page-hero__vector,.page-hero__contentclasses. Stop rendering the in-herointroparagraph.wp-content/themes/community-works-collaborative/views/page.php— Render the page-intro partial between the hero andthe_contentwhen theintrofield is non-empty.
Touched for verification only (no content changes)
package.json— Build script isnpm run build. No change.static/dist/theme.css— Regenerated bynpm run build. Not edited by hand.
Task 1: Add the ACF fields
Files:
- Modify:
wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json
Interfaces:
-
Produces: Two new fields readable via
get_field( 'hero_image' )andget_field( 'hero_vector' )in the page-hero partial. Both return arrays with at least['url']and['alt']keys when set, orfalsewhen empty. -
Step 1: Read the current ACF JSON
Read acf/group_60bfb84ae973c.json to confirm the current shape.
- Step 2: Add the two new field definitions
Insert the following two field objects into the fields array, after the call_to_actions field (the last existing field). Each new field mirrors the conditional_logic pattern used by heading and intro (show when hero_style !== 'none').
,
{
"key": "field_68new01heroimage",
"label": "Hero Image",
"name": "hero_image",
"aria-label": "",
"type": "image",
"instructions": "Optional background image for the page hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_68new02herovector",
"label": "Hero Vector",
"name": "hero_vector",
"aria-label": "",
"type": "image",
"instructions": "Optional decorative linework/vector rendered on the right side of the hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
}
- Step 3: Bump the
modifiedtimestamp
Change the top-level "modified": 1746895738 value to a fresh Unix timestamp. Use date +%s (or, on Windows PowerShell: [int][double]::Parse((Get-Date -UFormat %s))).
- Step 4: Validate the JSON
Run: cat acf/group_60bfb84ae973c.json | node -e "JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('valid')"
Expected: valid
- Step 5: Commit
git add acf/group_60bfb84ae973c.json
git commit -m "Add hero_image and hero_vector fields to Page Heading group"
Task 2: Create the page-intro partial
Files:
- Create:
wp-content/themes/community-works-collaborative/views/partials/page-intro.php
Interfaces:
-
Consumes:
get_field( 'intro' )— a non-empty string when the page has an intro. -
Produces: An HTML
<section class="page-intro ...">block with the intro text wrapped in a<p>. No new CSS file; uses Tailwind utilities only. -
Step 1: Create the partial file
Write views/partials/page-intro.php:
<?php
/**
* Page Intro Partial
*
* Renders the centered narrow intro section beneath the page hero.
* Loaded by page.php when the `intro` ACF field is non-empty.
*
* @package CWC
*/
namespace CWC;
$intro = get_field( 'intro' );
if ( ! $intro ) {
return;
}
?>
<section class="page-intro py-12 lg:py-20 text-center" aria-label="<?php echo esc_attr__( 'Page introduction' ); ?>">
<div class="container mx-auto max-w-3xl content-wrapper">
<p class="page-intro__text text-lg lg:text-xl leading-relaxed text-dark">
<?php echo wp_kses_post( $intro ); ?>
</p>
</div>
</section>
- Step 2: Lint the new file
Run: composer lint -- views/partials/page-intro.php
Expected: no errors. If PHPCS reports style issues, fix the file and re-run until clean.
- Step 3: Commit
git add views/partials/page-intro.php
git commit -m "Add page-intro partial for centered narrow intro section"
Task 3: Wire the intro partial into page.php
Files:
- Modify:
wp-content/themes/community-works-collaborative/views/page.php
Interfaces:
-
Consumes: The
page-intropartial (Task 2). Readsget_field( 'intro' ). -
Produces: When the
introfield is set, the page-intro partial renders between the hero (loaded byheader.php) andthe_content. When empty, only the body renders. -
Step 1: Read the current page.php
Read views/page.php to confirm current contents.
- Step 2: Replace page.php with the new version
Write views/page.php to match:
<?php
/**
* Single Pages
*
* @package CWC
* @since 1.0.0
*/
namespace CWC;
get_header();
$clsEntry = '';
// Determine classes based on sidebar presence
if ( hasSidebar() ) {
$classes = 'container grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-16 my-section mx-auto';
$clsEntry = 'lg:col-span-3';
} else {
$classes = 'container my-section lg:my-0 mx-auto';
}
?>
<article class="<?php echo esc_attr( $classes ); ?>">
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
<?php
// Centered intro section beneath the hero (rendered by header.php).
if ( get_field( 'intro' ) ) {
get_template_part( 'views/partials/page-intro' );
}
// Page body content
if ( have_posts() ) {
while ( have_posts() ) {
the_post();
the_content();
}
}
?>
</div>
<?php if ( hasSidebar() ) : ?>
<?php get_sidebar( 'page' ); ?>
<?php endif; ?>
</article>
<?php get_footer(); ?>
- Step 3: Lint the modified file
Run: composer lint -- views/page.php
Expected: no errors.
- Step 4: Commit
git add views/page.php
git commit -m "Render page-intro partial between hero and body in page.php"
Task 4: Add scoped CSS for the new hero media layers
Files:
- Create:
wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css - Modify:
wp-content/themes/community-works-collaborative/styles/blocks/index.css
Interfaces:
-
Produces: Three classes (
.page-hero,.page-hero__media,.page-hero__vector,.page-hero__content) plus a 767px mobile breakpoint. Used by the page-hero partial in Task 5. -
Step 1: Create the page-hero CSS file
Write styles/blocks/page-hero.css:
/* Page Hero partial styles (page-hero.php)
* Scoped to .page-hero to avoid affecting the homepage-hero block. */
.page-hero {
isolation: isolate;
position: relative;
}
.page-hero__media {
inset: 0;
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 30%);
mask-image: linear-gradient(to right, transparent 0%, black 30%);
position: absolute;
z-index: 0;
}
.page-hero__media img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.page-hero__vector {
bottom: 0;
height: auto;
pointer-events: none;
position: absolute;
right: 0;
width: clamp(18rem, 38vw, 32rem);
z-index: 1;
}
.page-hero__content {
position: relative;
z-index: 10;
}
@media (max-width: 767px) {
.page-hero__media {
-webkit-mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
}
.page-hero__vector {
bottom: 0;
left: 0;
right: auto;
width: 100vw;
}
}
- Step 2: Import the new file from the blocks index
Edit styles/blocks/index.css. After the line @import './misc.css'; add:
@import './page-hero.css';
The full file becomes:
/* Theme block styles */
@import './buttons.css';
@import './core.css';
@import './misc.css';
@import './page-hero.css';
- Step 3: Build the CSS
Run: npm run build
Expected: a fresh static/dist/theme.css is generated. The output should report a runtime in milliseconds. No errors.
- Step 4: Verify the new classes appear in the built CSS
Run: grep -c "page-hero__media" static/dist/theme.css
Expected: 1 or more.
Run: grep -c "page-hero__vector" static/dist/theme.css
Expected: 1 or more.
- Step 5: Commit
git add styles/blocks/page-hero.css styles/blocks/index.css static/dist/theme.css
git commit -m "Add page-hero media layer CSS and import into blocks bundle"
Task 5: Update page-hero.php to render image and vector and drop the in-hero intro
Files:
- Modify:
wp-content/themes/community-works-collaborative/views/partials/page-hero.php
Interfaces:
-
Consumes:
get_field( 'hero_image' )andget_field( 'hero_vector' )from the ACF group updated in Task 1. Existingbackground_color,is_dark,headingfields unchanged. -
Produces: A
.page-herosection with.page-hero__media(whenhero_imageis set) and.page-hero__vector(whenhero_vectoris set) absolutely-positioned behind a.page-hero__contentlayer. The in-hero intro paragraph is no longer rendered here. -
Step 1: Read the current page-hero.php
Read views/partials/page-hero.php to confirm current contents.
- Step 2: Replace page-hero.php with the new version
Write views/partials/page-hero.php:
<?php
/**
* Page Hero Partial
*
* @package CWC
*/
namespace CWC;
// Set variables
$bgColor = get_field( 'background_color' );
$isDark = get_field( 'is_dark' );
$heading = get_field( 'heading' );
$heroImage = get_field( 'hero_image' );
$heroVector = get_field( 'hero_vector' );
// Fallback for heading
if ( ! $heading ) {
$heading = getTheTitle();
}
// Additional logic for dark mode (if needed)
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
$isDark = true;
}
?>
<div class="page-hero bg-cover bg-no-repeat mb-12 py-12 lg:py-16 bg-dark text-light overflow-hidden <?php echo $isDark ? 'dark' : ''; ?>" <?php echo $bgColor ? 'style="background-color: ' . esc_attr( $bgColor ) . '"' : ''; ?>>
<?php if ( $heroImage ) : ?>
<div class="page-hero__media" aria-hidden="true">
<img
src="<?php echo esc_url( $heroImage['url'] ); ?>"
alt=""
loading="lazy"
role="presentation"
/>
</div>
<?php endif; ?>
<?php if ( $heroVector ) : ?>
<img
class="page-hero__vector"
src="<?php echo esc_url( $heroVector['url'] ); ?>"
alt=""
loading="lazy"
aria-hidden="true"
role="presentation"
/>
<?php endif; ?>
<div class="page-hero__content container mx-auto">
<div id="breadcrumbs">
<?php Breadcrumbs::render(); ?>
</div>
<div class="sm:text-center lg:items-start lg:text-left content-wrapper">
<?php
// Heading
if ( apply_filters( 'include_page_title_in_hero', true ) ) {
echo '<h1 class="mx-auto text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
echo wp_kses_post( $heading );
echo '</h1>';
} else {
echo '<span class="mx-auto block text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
echo wp_kses_post( $heading );
echo '</span>';
}
?>
</div>
</div>
</div>
Note: the in-hero <p> block for the intro field is removed. The intro now renders from page-intro.php in page.php.
- Step 3: Lint the modified file
Run: composer lint -- views/partials/page-hero.php
Expected: no errors.
- Step 4: Commit
git add views/partials/page-hero.php
git commit -m "Render hero_image and hero_vector in page-hero partial; drop in-hero intro"
Task 6: Write the Playwright test for the inner page
Files:
- Create:
wp-content/themes/community-works-collaborative/tests/inner-page.spec.js
Interfaces:
-
Consumes: The test page
http://community-works-collaborative.test/sample-page/(percontent/basic-wp-test-content.xml:7406). The page-intro partial (Task 2). The page-hero partial with new media layers (Task 5). The CSS file from Task 4. -
Produces: A Playwright test file that asserts:
- At desktop (1280px): page-hero, page-intro, body all render in that order. Heading and intro visible.
- At 402px: same order, vector fills lower portion of hero, intro is centered and within the 48rem (768px) max-width, no clipped text.
- At 320px: nothing clips.
- At 767px: vector scales proportionally, no clipping.
- The page renders without console errors (a final
page.on('pageerror')listener captures any).
-
Step 1: Create the test file
Write tests/inner-page.spec.js:
import { expect, test } from "@playwright/test";
const pageUrl =
process.env.TEST_PAGE_URL ||
"http://community-works-collaborative.test/sample-page/";
const getBox = async (locator) => {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return box;
};
const expectNear = (actual, expected, tolerance = 3) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
test.describe("inner page layout", () => {
test("desktop: hero, intro, body render in order with the new media layers", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
// Order check: hero, then intro, then content.
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
expect(heroBox.y).toBeLessThan(introBox.y);
expect(introBox.y).toBeLessThan(contentBox.y);
// Heading is rendered inside the hero.
await expect(hero.locator("h1").first()).toBeVisible();
// Intro is centered and capped at the max-w-3xl width.
const introInner = intro.locator(".content-wrapper").first();
const introInnerBox = await getBox(introInner);
expect(introInnerBox.width).toBeLessThanOrEqual(768 + 3);
// Page renders without JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: hero, intro, body order and intro width", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
expect(heroBox.y).toBeLessThan(introBox.y);
expect(introBox.y).toBeLessThan(contentBox.y);
// Intro fits within the mobile viewport.
const introInner = intro.locator(".content-wrapper").first();
const introInnerBox = await getBox(introInner);
expect(introInnerBox.x + introInnerBox.width).toBeLessThanOrEqual(
402 + 3,
);
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(320 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(320 + 3);
});
test("mobile 767: layout still holds before the desktop breakpoint", async ({
page,
}) => {
await page.setViewportSize({ width: 767, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(767 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(767 + 3);
});
});
- Step 2: Run the new test
Run: npx playwright test tests/inner-page.spec.js
Expected: 4 tests pass. If a test fails, read the failure, fix the underlying issue (template/CSS, not the test), and re-run until all 4 pass.
- Step 3: Run the full test suite to confirm no regressions
Run: npx playwright test
Expected: the existing 36 tests in tests/mobile-homepage.spec.js and tests/site-a11y.spec.js still pass, plus the 4 new ones (40 total).
- Step 4: Commit
git add tests/inner-page.spec.js
git commit -m "Add Playwright coverage for inner page layout at all viewports"
Task 7: Final quality gate
Files: none modified.
- Step 1: Run the production CSS build
Run: npm run build
Expected: a successful build with a fresh static/dist/theme.css.
- Step 2: Run PHPCS over the whole theme
Run: composer lint
Expected: no new errors. Existing errors (if any) are pre-existing and out of scope.
- Step 3: Run the full Playwright suite (final pass)
Run: npx playwright test
Expected: all tests pass (40+ tests, 0 failures).
- Step 4: Confirm no stray files
Run: git status
Expected: working tree clean.
- Step 5: Commit any leftover build artifacts
If static/dist/theme.css was rebuilt and not yet committed:
git add static/dist/theme.css
git commit -m "Rebuild theme.css after final quality gate"
If nothing to commit, skip this step.
Self-Review
Spec coverage:
| Spec requirement | Task |
|---|---|
Add hero_image and hero_vector fields to Page Heading ACF group |
Task 1 |
Render hero_image in views/partials/page-hero.php with mask treatment |
Task 5 (markup) + Task 4 (CSS) |
Render hero_vector in views/partials/page-hero.php, right-anchored |
Task 5 + Task 4 |
Stop rendering the in-hero intro paragraph |
Task 5 |
New views/partials/page-intro.php partial with centered narrow column |
Task 2 |
page.php renders intro partial between hero and body when intro is set |
Task 3 |
New views/partials/page-hero.css for media-layer styles |
Task 4 |
| Playwright tests at desktop, 402px, 320px, 767px | Task 6 |
| No new ACF blocks | Verified — none added |
| Front page, single, archive, search, 404 unchanged | Verified — no template changes outside page.php and the partial |
| Bottom dark block is the existing footer (not a new CTA) | Verified — no contact-block added |
npm run build, full Playwright, composer lint |
Task 7 |
Placeholder scan: No TBD/TODO. Every step has exact code or commands. No "similar to Task N" references.
Type / interface consistency:
get_field( 'hero_image' )defined in Task 1, consumed in Task 5 as['url']array. Same forhero_vector. ✓get_template_part( 'views/partials/page-intro' )used in Task 3, the file created in Task 2. ✓.page-hero,.page-hero__media,.page-hero__vector,.page-hero__contentclasses defined in Task 4 CSS, used in Task 5 markup, asserted in Task 6. ✓pageUrlin Task 6 default points to the samesample-pagefixture referenced in the spec. ✓