diff --git a/docs/superpowers/plans/2026-07-01-inner-page-layout.md b/docs/superpowers/plans/2026-07-01-inner-page-layout.md new file mode 100644 index 0000000..680c3e9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-inner-page-layout.md @@ -0,0 +1,744 @@ +# 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`, `expectNear` helpers; `await expect(...).toBeVisible()` before reading dimensions of lazy-loaded images) +- The hero is loaded via `header.php:60` via `get_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 same `conditional_logic` rule as the existing `heading` and `intro` fields: shown when `hero_style !== 'none'`. +- Per-component CSS for the hero media layers goes in a new `styles/blocks/page-hero.css` and is imported from `styles/blocks/index.css`. Scoping is enforced with a `.page-hero` class root. +- Page slug for the dev fixture: `sample-page` (per `content/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 existing `intro` ACF 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` — Add `hero_image` and `hero_vector` image fields. Bump `modified` timestamp. +- `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. Render `hero_image` and `hero_vector` as absolutely-positioned media layers. Add `.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content` classes. Stop rendering the in-hero `intro` paragraph. +- `wp-content/themes/community-works-collaborative/views/page.php` — Render the page-intro partial between the hero and `the_content` when the `intro` field is non-empty. + +### Touched for verification only (no content changes) + +- `package.json` — Build script is `npm run build`. No change. +- `static/dist/theme.css` — Regenerated by `npm 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' )` and `get_field( 'hero_vector' )` in the page-hero partial. Both return arrays with at least `['url']` and `['alt']` keys when set, or `false` when 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'`). + +```json +, +{ + "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 `modified` timestamp** + +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** + +```bash +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 `
` block with the intro text wrapped in a `

`. No new CSS file; uses Tailwind utilities only. + +- [ ] **Step 1: Create the partial file** + +Write `views/partials/page-intro.php`: + +```php + + +

+
+

+ +

+
+
+``` + +- [ ] **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** + +```bash +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-intro` partial (Task 2). Reads `get_field( 'intro' )`. +- Produces: When the `intro` field is set, the page-intro partial renders between the hero (loaded by `header.php`) and `the_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 + + +
+
+ +
+ + + + +
+ + +``` + +- [ ] **Step 3: Lint the modified file** + +Run: `composer lint -- views/page.php` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +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`: + +```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: + +```css +@import './page-hero.css'; +``` + +The full file becomes: + +```css +/* 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** + +```bash +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' )` and `get_field( 'hero_vector' )` from the ACF group updated in Task 1. Existing `background_color`, `is_dark`, `heading` fields unchanged. +- Produces: A `.page-hero` section with `.page-hero__media` (when `hero_image` is set) and `.page-hero__vector` (when `hero_vector` is set) absolutely-positioned behind a `.page-hero__content` layer. 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 + + +
> + + + + + + + + + +
+ + +
+ '; + echo wp_kses_post( $heading ); + echo ''; + } else { + echo ''; + echo wp_kses_post( $heading ); + echo ''; + } + ?> +
+
+
+``` + +Note: the in-hero `

` 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** + +```bash +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/` (per `content/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`: + +```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** + +```bash +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: + +```bash +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 for `hero_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__content` classes defined in Task 4 CSS, used in Task 5 markup, asserted in Task 6. ✓ +- `pageUrl` in Task 6 default points to the same `sample-page` fixture referenced in the spec. ✓