27 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/page.php— Render the page-intro partial between the hero andthe_contentwhen theintrofield is non-empty. (Note:page.phpis at the theme root, notviews/page.php— WordPress convention. Use tabs to match the existing file and the WordPress coding standard enforced by phpcs.)
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="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>
Note: the inner div intentionally does not include the container Tailwind class. The project's styles/base/global.css defines .container { max-width: 80.5rem; } (1288px), which is loaded after the Tailwind utilities in the cascade, so it would override max-w-3xl (768px) at desktop and produce an over-wide intro. Use only mx-auto max-w-3xl so the cap applies.
- 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/page.php(at the theme root, notviews/page.php— WordPress convention)
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 page.php to confirm current contents. Note the indentation style — the existing file uses tabs (WordPress coding standard). The brief's code block uses 4-space indentation as a transcription convention; convert to tabs to match the surrounding code.
- Step 2: Replace page.php with the new version
Write page.php to match (using tabs for indentation):
<?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 ); ?>">
<?php
// Centered intro section between the hero and the body content.
if ( get_field( 'intro' ) ) {
get_template_part( 'views/partials/page-intro' );
}
?>
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
<?php
// 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(); ?>
Note: the page-intro partial is loaded as a sibling of <div class="entry-content">, not inside it. When intro is a child of entry-content, the two bounding boxes share the same top y, so the test's expect(introBox.y).toBeLessThan(contentBox.y) order assertion fails. Moving intro out of entry-content makes the order check hold.
- Step 3: Lint the modified file
Run: vendor/bin/phpcs page.php
Expected: 1/1 clean. The pre-existing CRLF EOL errors in unrelated files (per Task 2's report) are out of scope; don't run composer lint (full project) for this task.
- Step 4: Commit
git add 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-intro and no-asset fallback tests | Task 6 (added by final review fix) |
| 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 |
Production-template fixes applied by the Task 6 implementer (documented here so future readers don't reproduce the bugs):
| Defect the brief's assertions revealed | Fix | Why |
|---|---|---|
expect(introBox.y).toBeLessThan(contentBox.y) failed because the page-intro partial was rendered as a child of <div class="entry-content">, so the two bounding boxes shared the same top y. |
page.php now loads the page-intro partial as a sibling of the entry-content div, not inside it. |
Intro and body become peers in the layout flow, so the order check holds. |
The intro's <div class="container mx-auto max-w-3xl content-wrapper"> produced an ~1184px wide intro on desktop (the max-w-3xl cap was overridden by .container's max-width: 80.5rem from styles/base/global.css, which loads after the Tailwind utilities in the cascade). |
views/partials/page-intro.php now uses <div class="mx-auto max-w-3xl content-wrapper"> (no container class). |
max-w-3xl (768px) is the only constraint, so the intro is correctly capped on desktop. |
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. ✓