Files
CWC/docs/superpowers/plans/2026-07-04-contact-page.md
Keith Solomon 01c6fb7831
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
feature: Set up contact info block and map
2026-07-04 19:22:26 -05:00

31 KiB
Raw Permalink Blame History

Contact Page + Hero Re-Scope 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 contact page layout (two-column 50/50 with contact info, Gravity Forms, and a Leaflet placeholder) and re-scope the existing inner-page hero to render only on the Services page, its children, and its grandchildren.

Architecture: The contact page work is a block-level change: rewrite views/blocks/contact-info/contact-info.php and contact-info.css, add three SVG icons, and add a Playwright test. The hero re-scope is a single PHP helper in lib/extras.php plus a one-line change in header.php. Both are independently testable. A test bypass constant lets the existing inner-page.spec.js fixtures keep working without moving them under the Services page.

Tech Stack: WordPress 6.x, ACF Pro, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).

Global Constraints

  • Tabs for PHP indentation (project standard, see composer.json and existing files).
  • The theme's Tailwind build outputs static/dist/theme.css; that file is gitignored but committed with git add -f per project convention.
  • PHPCS uses the WordPress coding standard; composer lint must pass.
  • All Playwright tests in tests/*.spec.js must pass; the existing mobile-homepage.spec.js has 4 known pre-existing failures that are out of scope.
  • The acf/contact-info block currently has an InnerBlocks call. Removing it is a breaking change for any page that was using the block as a wrapper. A grep at task start confirms there are zero such pages.
  • The hero re-scope uses slug services to find the Services page (get_page_by_path( 'services' )). The Services page must exist on the live site with slug services and the test fixture page must continue to render the hero even though it is not a Services descendant (handled by CWC_TEST_BYPASS_HERO_GATE).
  • The Leaflet map embed and the map pin SVG are not yet available; the right column renders as a styled placeholder.

File Structure

File Responsibility Created/Modified
views/blocks/contact-info/contact-info.php Renders the two-column 50/50 layout: heading + address/email/phone + Gravity Form on the left, Leaflet placeholder on the right. Modify
views/blocks/contact-info/contact-info.css Layout, icon sizing, Gravity Forms field overrides, and the right-edge break-out for the map. Modify
static/img/contact/icon-address.svg Map pin outline, 24×24, stroke="currentColor" fill="none". Create
static/img/contact/icon-email.svg Envelope outline, 24×24, stroke="currentColor" fill="none". Create
static/img/contact/icon-phone.svg Phone outline, 24×24, stroke="currentColor" fill="none". Create
lib/extras.php Add isServicesDescendant() and CWC_TEST_BYPASS_HERO_GATE constant. Modify
header.php Update $showHero to also require isServicesDescendant() on the page post type. Modify
static/dist/theme.css Rebuilt via npm run build. Modified (committed with git add -f).
tests/contact-page.spec.js New Playwright spec for the contact page layout. Create
tests/services-hero-scope.spec.js New Playwright spec for the hero re-scope. Create

Task 1: Add the three contact icon SVGs

Files:

  • Create: static/img/contact/icon-address.svg
  • Create: static/img/contact/icon-email.svg
  • Create: static/img/contact/icon-phone.svg

Interfaces:

  • Consumes: nothing
  • Produces: three static SVG assets that the contact-info block references via get_theme_file_uri( '/static/img/contact/...svg' ).

The user is providing the final SVGs. Until they arrive, use these placeholders (24×24 viewBox, stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"). The CSS colors them via currentColor.

  • Step 1: Create the static/img/contact directory

Run: mkdir -p static/img/contact Expected: directory created; no error.

  • Step 2: Write static/img/contact/icon-address.svg
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
  <path d="M20 10c0 7-8 12-8 12s-8-5-8-12a8 8 0 0 1 16 0Z"/>
  <circle cx="12" cy="10" r="3"/>
</svg>
  • Step 3: Write static/img/contact/icon-email.svg
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
  <rect x="3" y="5" width="18" height="14" rx="2"/>
  <path d="m3 7 9 6 9-6"/>
</svg>
  • Step 4: Write static/img/contact/icon-phone.svg
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
  <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.8a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.84.57 2.8.7A2 2 0 0 1 22 16.92Z"/>
</svg>
  • Step 5: Verify all three files exist and are valid XML

Run: ls -la static/img/contact/ && for f in static/img/contact/*.svg; do echo "== $f =="; head -1 "$f"; done Expected: three files, each starting with <svg xmlns=....

  • Step 6: Commit
git add static/img/contact/
git commit -m "feat(contact): add address, email, phone icon SVGs"

Task 2: Rewrite views/blocks/contact-info/contact-info.php

Files:

  • Modify: views/blocks/contact-info/contact-info.php

Interfaces:

  • Consumes: the global contact_info option (get_field( 'contact_info', 'option' )['address' | 'email' | 'phone']).

  • Produces: a <section class="contact-info"> with .contact-info__grid > .contact-info__details and .contact-info__map. The details column has the heading, an <ul> of address/email/phone <li>s with .contact-info__icon spans, and a .contact-info__form div with do_shortcode( '[gravityform id="1" title="false" description="false" ajax="true"]' ). The map column has <div id="contact-map">.

  • Step 1: Verify no other page uses the InnerBlocks form of this block

Run:

grep -rn "contact-info" wp-content/themes/community-works-collaborative/views/ --include="*.php" | grep -v "contact-info/contact-info.php" | grep -v "contact-info/block.json"

Expected: no matches. If matches exist, list them and confirm the InnerBlocks content is empty for each before proceeding.

  • Step 2: Replace views/blocks/contact-info/contact-info.php with the new content

Write the entire file:

<?php
/**
 * Block Name: Contact Info
 *
 * Two-column contact page layout: contact info, address/email/phone,
 * and a Gravity Forms contact form on the left; Leaflet map placeholder
 * on the right. Reads from the global `contact_info` option.
 *
 * @package CWC
 */

namespace CWC;

$info    = get_field( 'contact_info', 'option' );
$address = $info['address'] ?? '';
$email   = $info['email']   ?? '';
$phone   = $info['phone']   ?? '';

$classes = 'contact-info';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>

<section <?php echo esc_attr( $wrapper ); ?>>
    <div class="contact-info__grid">
        <div class="contact-info__details">
            <h1 class="contact-info__heading">Contact</h1>

            <ul class="contact-info__items">
                <?php if ( $address ) : ?>
                    <li class="contact-info__item contact-info__item--address">
                        <span class="contact-info__icon" aria-hidden="true">
                            <img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-address.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
                        </span>
                        <span class="contact-info__value"><?php echo wp_kses_post( $address ); ?></span>
                    </li>
                <?php endif; ?>

                <?php if ( $email ) : ?>
                    <li class="contact-info__item contact-info__item--email">
                        <span class="contact-info__icon" aria-hidden="true">
                            <img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-email.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
                        </span>
                        <a class="contact-info__value" href="mailto:<?php echo esc_attr( $email ); ?>"><?php echo esc_html( $email ); ?></a>
                    </li>
                <?php endif; ?>

                <?php if ( $phone ) : ?>
                    <li class="contact-info__item contact-info__item--phone">
                        <span class="contact-info__icon" aria-hidden="true">
                            <img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-phone.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
                        </span>
                        <a class="contact-info__value" href="tel:<?php echo esc_attr( $phone ); ?>"><?php echo esc_html( $phone ); ?></a>
                    </li>
                <?php endif; ?>
            </ul>

            <div class="contact-info__form">
                <?php echo do_shortcode( '[gravityform id="1" title="false" description="false" ajax="true"]' ); ?>
            </div>
        </div>

        <div class="contact-info__map" id="contact-map" aria-label="Map of downtown Winnipeg" role="region">
            <!-- Leaflet map will mount here. Pin: orange-01 + white map marker (TBD). -->
        </div>
    </div>
</section>
  • Step 3: Verify the file parses (no PHP syntax errors)

Run: php -l views/blocks/contact-info/contact-info.php Expected: No syntax errors detected in views/blocks/contact-info/contact-info.php.

  • Step 4: Commit
git add views/blocks/contact-info/contact-info.php
git commit -m "feat(contact): rewrite contact-info block to two-column layout"

Task 3: Write views/blocks/contact-info/contact-info.css (layout + icons)

Files:

  • Modify: views/blocks/contact-info/contact-info.css

Interfaces:

  • Consumes: the new HTML structure from Task 2.

  • Produces: a mobile-first stylesheet that lays out the two columns, sizes the icons, and breaks the right column out to the viewport edge on desktop.

  • Step 1: Replace views/blocks/contact-info/contact-info.css with the new content

/* Contact Info block styles */

.contact-info {
    background: var(--color-white);
    padding-block: clamp(3rem, 6vw, 4rem);
    position: relative;
}

.contact-info__grid {
    display: grid;
    gap: 2rem;
    grid-template-columns: 1fr;
    position: relative;
}

.contact-info__details {
    max-width: 36rem;
    padding-inline: clamp(1.5rem, 5vw, 3rem);
}

.contact-info__heading {
    color: var(--color-cwc-blue-01);
    font-family: var(--font-quincy, 'Quincy', serif);
    font-size: clamp(2.5rem, 6vw, 4.5rem);
    font-weight: 400;
    line-height: 1.05;
    margin: 0 0 1.5rem;
}

.contact-info__items {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    list-style: none;
    margin: 0 0 2.5rem;
    padding: 0;
}

.contact-info__item {
    align-items: flex-start;
    color: var(--color-dark);
    display: flex;
    font-size: 1rem;
    gap: 0.75rem;
    line-height: 1.5;
}

.contact-info__icon {
    align-items: center;
    color: var(--color-cwc-blue-03);
    display: inline-flex;
    flex-shrink: 0;
    height: 1.5rem;
    justify-content: center;
    width: 1.5rem;
}

.contact-info__icon img {
    display: block;
    height: 100%;
    width: 100%;
}

.contact-info__value {
    color: inherit;
    text-decoration: none;
}

a.contact-info__value:hover {
    color: var(--color-secondary);
}

.contact-info__map {
    background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
    border-radius: 0.5rem;
    min-height: 24rem;
    position: relative;
}

@media (min-width: 1024px) {
    .contact-info__grid {
        grid-template-columns: 1fr 1fr;
    }

    .contact-info__details {
        padding-inline: clamp(1.5rem, 5vw, 3rem) 0;
    }

    .contact-info__map {
        align-self: stretch;
        border-radius: 0;
        margin-right: calc(50% - 50vw);
        margin-left: 0;
        min-height: 32rem;
        width: calc(50vw - 0px);
    }
}

@media (max-width: 767px) {
    .contact-info {
        padding-block: 2rem;
    }

    .contact-info__map {
        margin-inline: clamp(1.5rem, 5vw, 3rem);
        min-height: 18rem;
    }
}
  • Step 2: Add the import to styles/blocks/index.css if not already present

Run: grep -n "contact-info" styles/blocks/index.css Expected: a matching line. If not present, add the views-relative path (the file lives at views/blocks/contact-info/contact-info.css, NOT at styles/blocks/, so a sibling-relative path will fail the build):

@import '../../views/blocks/contact-info/contact-info.css';

Why views-relative, not sibling-relative: styles/blocks/ only contains buttons.css, core.css, misc.css, and page-hero.css. The new CSS is at views/blocks/contact-info/contact-info.css (so the per-block style: ['file:./contact-info.css'] in block.json resolves for the block editor preview). A sibling-relative import would silently produce "Can't resolve './contact-info.css' in 'styles/blocks'" and fail the build.

  • Step 3: Commit
git add views/blocks/contact-info/contact-info.css styles/blocks/index.css
git commit -m "feat(contact): add two-column layout and icon styles"

Task 4: Add Gravity Forms field overrides to contact-info.css

Files:

  • Modify: views/blocks/contact-info/contact-info.css

Interfaces:

  • Consumes: the layout from Task 3; the gform_* HTML output from Gravity Forms.

  • Produces: uppercase CWC Blue 01 labels, right-aligned CWC Orange 01 required asterisks, 30% opacity CWC Blue 03 input fills, slightly rounded corners, comfortable padding, a taller message textarea, and a submit button styled like the header Contact button.

  • Step 1: Append the Gravity Forms overrides to the end of views/blocks/contact-info/contact-info.css

/* Gravity Forms field overrides (scoped to .contact-info__form) */

.contact-info__form .gform_wrapper {
    margin: 0;
    max-width: none;
}

.contact-info__form .gfield {
    margin-bottom: 1.25rem;
}

.contact-info__form .gfield_label {
    color: var(--color-cwc-blue-01);
    display: flex;
    font-size: 0.875rem;
    font-weight: 700;
    justify-content: space-between;
    letter-spacing: 0.05em;
    line-height: 1.2;
    margin-bottom: 0.5rem;
    text-transform: uppercase;
}

.contact-info__form .gfield_required {
    color: var(--color-cwc-orange-01);
    font-weight: 700;
    margin-left: 0.25rem;
}

.contact-info__form input[type="text"],
.contact-info__form input[type="email"],
.contact-info__form input[type="tel"],
.contact-info__form input[type="url"],
.contact-info__form textarea,
.contact-info__form select {
    background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
    border: 1px solid var(--color-cwc-blue-03);
    border-radius: 0.375rem;
    color: var(--color-dark);
    font-size: 1rem;
    line-height: 1.5;
    padding: 0.75rem 1rem;
    width: 100%;
}

.contact-info__form input[type="text"]:focus,
.contact-info__form input[type="email"]:focus,
.contact-info__form input[type="tel"]:focus,
.contact-info__form input[type="url"]:focus,
.contact-info__form textarea:focus,
.contact-info__form select:focus {
    border-color: var(--color-cwc-blue-01);
    outline: 2px solid var(--color-cwc-blue-01);
    outline-offset: 2px;
}

.contact-info__form textarea {
    min-height: 10rem;
    resize: vertical;
}

.contact-info__form .gform_footer {
    margin-top: 1.5rem;
}

.contact-info__form .gform_footer input[type="submit"],
.contact-info__form .gform_footer button[type="submit"] {
    background: var(--background-image-button-gradient);
    border: 3px solid var(--color-secondary);
    border-radius: 1.25rem 0.25rem 1.25rem 0.25rem;
    color: var(--color-white);
    cursor: pointer;
    font-size: 1.125rem;
    font-weight: 700;
    padding: 0.625rem 2rem;
    transition: background 200ms, border-color 200ms, color 200ms;
}

.contact-info__form .gform_footer input[type="submit"]:hover,
.contact-info__form .gform_footer button[type="submit"]:hover {
    background: var(--color-secondary);
    border-color: var(--color-secondary);
}

.contact-info__form .validation_message {
    color: var(--color-danger);
    font-style: italic;
    margin-top: 0.25rem;
}
  • Step 2: Commit
git add views/blocks/contact-info/contact-info.css
git commit -m "feat(contact): style Gravity Forms fields and submit button"

Task 5: Build the dist CSS and verify

Files:

  • Modify: static/dist/theme.css (committed with git add -f)

Interfaces:

  • Consumes: the new views/blocks/contact-info/contact-info.css and any touched source files.

  • Produces: a rebuilt static/dist/theme.css that includes the new contact-info styles.

  • Step 1: Run the build

Run: npm run build Expected: build completes without errors. The dist file is regenerated.

  • Step 2: Verify the new classes are present in the dist CSS

Run:

grep -c "contact-info" static/dist/theme.css

Expected: at least 10 matches (a count of 0 means the build did not pick up the new CSS).

  • Step 3: Commit the rebuilt dist
git add -f static/dist/theme.css
git commit -m "build: regenerate dist with contact-info styles"

Task 6: Add isServicesDescendant() and the bypass constant to lib/extras.php

Files:

  • Modify: lib/extras.php

Interfaces:

  • Consumes: the global $post and an optional services-page override.

  • Produces: isServicesDescendant( ?WP_Post $post = null, ?int $services_id = null ): bool that returns true for the Services page, its children, and its grandchildren. Returns false if the Services page does not exist. A CWC_TEST_BYPASS_HERO_GATE constant (default false; can be flipped to true in test setup) bypasses the check.

  • Step 1: Append the constant and helper to lib/extras.php

Find the end of the hasPageHeader() function (the return true; } block near the bottom of the relevant section). After that function's closing brace, add:

/**
 * Bypass constant for the inner-page hero ancestor check.
 *
 * When `true`, `isServicesDescendant()` returns `true` for every page.
 * Set this in test setup (e.g. in `wp-config.php` or a test bootstrap) so
 * test fixture pages that are not Services descendants can still render
 * the hero.
 */
if ( ! defined( 'CWC_TEST_BYPASS_HERO_GATE' ) ) {
	define( 'CWC_TEST_BYPASS_HERO_GATE', false );
}

/**
 * Checks if the current page is the Services page, a child of it, or
 * a grandchild of it. Used to gate the inner-page hero.
 *
 * @param WP_Post|null $post        The post to check. Defaults to the global `$post`.
 * @param int|null     $services_id Override the Services page ID. Defaults to
 *                                  the page with slug `services`.
 *
 * @return bool true if the post is the Services page or a descendant up to 2 levels.
 */
function isServicesDescendant( $post = null, $services_id = null ) {
	if ( CWC_TEST_BYPASS_HERO_GATE ) {
		return true;
	}

	if ( null === $post ) {
		global $post;
	}

	if ( ! $post instanceof WP_Post ) {
		return false;
	}

	if ( null === $services_id ) {
		$services = get_page_by_path( 'services' );
		if ( ! $services ) {
			return false;
		}
		$services_id = (int) $services->ID;
	}

	$services_id = (int) $services_id;

	if ( (int) $post->ID === $services_id ) {
		return true;
	}

	if ( (int) $post->post_parent === $services_id ) {
		return true;
	}

	$parent = get_post( $post->post_parent );
	if ( $parent && (int) $parent->post_parent === $services_id ) {
		return true;
	}

	return false;
}
  • Step 2: Verify the file parses

Run: php -l lib/extras.php Expected: No syntax errors detected in lib/extras.php.

  • Step 3: Commit
git add lib/extras.php
git commit -m "feat(hero): add isServicesDescendant() helper and bypass constant"

Task 7: Update header.php to gate the hero on isServicesDescendant()

Files:

  • Modify: header.php

Interfaces:

  • Consumes: isServicesDescendant() from Task 6.

  • Produces: a $showHero calculation that requires isServicesDescendant() && hero_style === 'default' for the page post type. The other gates (is_home() || is_archive() || ...) are unchanged.

  • Step 1: Update $showHero in header.php

Replace the existing $showHero block:

$showHero = in_array(
    true,
    array(
        isServicesDescendant() && get_field( 'hero_style' ) === 'default',
        is_home(),
        is_archive(),
        is_single(),
        is_search(),
        is_404(),
    ),
    true
);
  • Step 2: Verify the file parses

Run: php -l header.php Expected: No syntax errors detected in header.php.

  • Step 3: Commit
git add header.php
git commit -m "feat(hero): gate inner-page hero on Services descendants"

Task 8: Add Playwright test for the contact page

Files:

  • Create: tests/contact-page.spec.js

Interfaces:

  • Consumes: the rendered contact page at /contact/.

  • Produces: a Playwright spec that verifies the two-column layout, icon rows, form area, and Leaflet placeholder render at desktop and mobile viewports, with no console errors and no axe violations.

  • Step 1: Create tests/contact-page.spec.js

import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;

const contactUrl =
    process.env.TEST_CONTACT_URL ||
    "http://community-works-collaborative.test/contact/";

test.describe("contact page", () => {
    test("desktop: two-column layout with form area and map placeholder", async ({
        page,
    }) => {
        const pageErrors = [];
        page.on("pageerror", (err) => pageErrors.push(err.message));

        await page.setViewportSize({ width: 1280, height: 900 });
        await page.goto(contactUrl);

        // No hero on the contact page.
        expect(await page.locator(".page-hero").count()).toBe(0);

        // Block renders.
        const block = page.locator(".contact-info").first();
        await expect(block).toBeVisible();

        // Left column has heading, items, and form area.
        const heading = page.locator(".contact-info__heading").first();
        await expect(heading).toBeVisible();
        await expect(heading).toHaveText(/contact/i);

        const items = page.locator(".contact-info__item");
        const itemsCount = await items.count();
        expect(itemsCount).toBeGreaterThanOrEqual(1);

        const formArea = page.locator(".contact-info__form").first();
        await expect(formArea).toBeAttached();

        // Right column has the Leaflet placeholder.
        const map = page.locator(".contact-info__map").first();
        await expect(map).toBeAttached();
        await expect(map).toHaveId("contact-map");

        // No JS errors.
        expect(pageErrors).toEqual([]);
    });

    test("mobile 402: single column, map drops below form, no horizontal overflow", async ({
        page,
    }) => {
        const pageErrors = [];
        page.on("pageerror", (err) => pageErrors.push(err.message));

        await page.setViewportSize({ width: 402, height: 874 });
        await page.goto(contactUrl);

        const details = page.locator(".contact-info__details").first();
        const map = page.locator(".contact-info__map").first();

        await expect(details).toBeVisible();
        await expect(map).toBeAttached();

        const detailsBox = await details.boundingBox();
        const mapBox = await map.boundingBox();

        expect(detailsBox).not.toBeNull();
        expect(mapBox).not.toBeNull();
        expect(mapBox.x).toBeGreaterThanOrEqual(detailsBox.x - 1);
        expect(mapBox.x + mapBox.width).toBeLessThanOrEqual(402 + 3);

        expect(pageErrors).toEqual([]);
    });

    test("mobile 320: nothing clips horizontally", async ({ page }) => {
        await page.setViewportSize({ width: 320, height: 800 });
        await page.goto(contactUrl);

        const block = page.locator(".contact-info").first();
        await expect(block).toBeVisible();

        const blockBox = await block.boundingBox();
        expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
    });

    test("axe: no violations", async ({ page }, testInfo) => {
        await page.setViewportSize({ width: 1280, height: 900 });
        await page.goto(contactUrl);

        const results = await new AxeBuilder({ page })
            .withTags([
                "wcag2a",
                "wcag2aa",
                "wcag21a",
                "wcag21aa",
                "wcag22a",
                "wcag22aa",
            ])
            .analyze();

        await testInfo.attach("accessibility-scan-results", {
            body: JSON.stringify(results, null, 2),
            contentType: "application/json",
        });

        expect(results.violations).toEqual([]);
    });
});
  • Step 2: Run the new test

Run: npx playwright test tests/contact-page.spec.js Expected: all 4 tests pass. If a test fails, fix the underlying code (do not weaken the test) and re-run.

  • Step 3: Commit
git add tests/contact-page.spec.js
git commit -m "test(contact): add contact-page Playwright spec"

Task 9: Add Playwright test for the hero re-scope

Files:

  • Create: tests/services-hero-scope.spec.js

Interfaces:

  • Consumes: rendered pages: Services, a child of Services, a grandchild, a non-Services page (e.g. About), and /news/ (blog index).
  • Produces: a Playwright spec that asserts the hero renders on the first three and not on the latter two.

Pre-flight check (this task's pre-step): confirm that the test fixture pages exist on the live site. If the test site is fresh, the developer may need to seed these pages in beforeAll. The spec uses environment variables so the URLs can be overridden:

TEST_SERVICES_URL=http://community-works-collaborative.test/services/
TEST_SERVICES_CHILD_URL=http://community-works-collaborative.test/communication-services/
TEST_SERVICES_GRANDCHILD_URL=http://community-works-collaborative.test/communication-services/some-grandchild/
TEST_NON_SERVICES_URL=http://community-works-collaborative.test/about/
  • Step 1: Create tests/services-hero-scope.spec.js
import { expect, test } from "@playwright/test";

const servicesUrl =
    process.env.TEST_SERVICES_URL ||
    "http://community-works-collaborative.test/services/";
const childUrl =
    process.env.TEST_SERVICES_CHILD_URL ||
    "http://community-works-collaborative.test/communication-services/";
const grandchildUrl =
    process.env.TEST_SERVICES_GRANDCHILD_URL ||
    "http://community-works-collaborative.test/communication-services/sample-grandchild/";
const nonServicesUrl =
    process.env.TEST_NON_SERVICES_URL ||
    "http://community-works-collaborative.test/about/";
const blogUrl =
    process.env.TEST_BLOG_URL ||
    "http://community-works-collaborative.test/news/";

test.describe("hero ancestor scope", () => {
    test("Services page renders the hero", async ({ page }) => {
        await page.goto(servicesUrl);
        await expect(page.locator(".page-hero").first()).toBeVisible();
    });

    test("child of Services renders the hero", async ({ page }) => {
        await page.goto(childUrl);
        await expect(page.locator(".page-hero").first()).toBeVisible();
    });

    test("grandchild of Services renders the hero", async ({ page }) => {
        await page.goto(grandchildUrl);
        await expect(page.locator(".page-hero").first()).toBeVisible();
    });

    test("non-Services page does NOT render the hero", async ({ page }) => {
        await page.goto(nonServicesUrl);
        expect(await page.locator(".page-hero").count()).toBe(0);
    });

    test("blog index still renders the hero (bypasses ancestor gate)", async ({
        page,
    }) => {
        await page.goto(blogUrl);
        await expect(page.locator(".page-hero").first()).toBeVisible();
    });
});
  • Step 2: Verify the test fixture URLs exist

Run (per URL):

curl -sI "$url" | head -1

Expected: HTTP/1.1 200 OK (or 301/302 for trailing-slash redirects). If a URL returns 404, the test will need a beforeAll that seeds the page; flag this and do not proceed without a real fixture.

  • Step 3: Run the new test

Run: npx playwright test tests/services-hero-scope.spec.js Expected: all 5 tests pass. If nonServicesUrl still shows the hero, verify the live page's post_parent chain (the About page must not be a child or grandchild of /services/).

  • Step 4: Commit
git add tests/services-hero-scope.spec.js
git commit -m "test(hero): add ancestor-scope Playwright spec"

Task 10: Run the full Playwright suite + PHPCS

Files:

  • Modify: static/dist/theme.css (only if not already rebuilt).

  • Modify: any source files if a regression is found.

  • Step 1: Run the full Playwright suite

Run: npx playwright test Expected: all tests pass except the 4 known pre-existing mobile-homepage.spec.js failures. The new tests/contact-page.spec.js and tests/services-hero-scope.spec.js pass.

  • Step 2: Run PHPCS

Run: composer lint Expected: no errors. If errors are found, fix them (most likely are tab-vs-spaces in PHP files; use composer lint:fix to auto-fix).

  • Step 3: Verify the rebuilt dist is current

Run: git status static/dist/theme.css Expected: nothing to commit, working tree clean (i.e. the dist file is up to date with the source CSS). If uncommitted, run git add -f static/dist/theme.css and commit with chore: sync dist after lint/fix.

  • Step 4: Final commit (if anything changed in step 1-3)
git add -A
git commit -m "chore: final pass after Playwright and PHPCS"

Self-Review Notes

  • Spec coverage: Task 1 (icons), Task 2 (block PHP), Task 3 (layout CSS), Task 4 (form CSS), Task 5 (build), Task 6 (PHP helper + bypass), Task 7 (header.php), Task 8 (contact test), Task 9 (hero-scope test), Task 10 (full suite). Every spec section is covered.
  • Placeholder scan: No "TBD", "TODO", or "implement later" in any step. The "Leaflet placeholder" and "map pin SVG TBD" comments in the PHP and CSS are intentional (they document where the embed will mount, not unfinished work).
  • Type consistency: isServicesDescendant( ?WP_Post $post = null, ?int $services_id = null ): bool is used identically in Task 6 (definition) and Task 7 (call site). CWC_TEST_BYPASS_HERO_GATE is defined in Task 6 and respected in Task 7 by virtue of the helper checking it.
  • Spec section "Out of Scope" (Leaflet embed, map pin SVG, ACF fields on the block, audit of existing InnerBlocks usage): all flagged in the spec. Task 2's Step 1 audit confirms no InnerBlocks usage before removing it.