`.
+
+- [ ] **Step 1: Verify no other page uses the `InnerBlocks` form of this block**
+
+Run:
+```bash
+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
+
+
+
+```
+
+- [ ] **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**
+
+```bash
+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**
+
+```css
+/* 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):
+
+```css
+@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**
+
+```bash
+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`**
+
+```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**
+
+```bash
+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:
+```bash
+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**
+
+```bash
+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:
+
+```php
+/**
+ * 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**
+
+```bash
+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:
+
+```php
+$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**
+
+```bash
+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`**
+
+```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**
+
+```bash
+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:
+
+```bash
+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`**
+
+```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):
+```bash
+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**
+
+```bash
+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)**
+
+```bash
+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.
diff --git a/docs/superpowers/specs/2026-07-04-contact-page-design.md b/docs/superpowers/specs/2026-07-04-contact-page-design.md
new file mode 100644
index 0000000..57fcae5
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-04-contact-page-design.md
@@ -0,0 +1,274 @@
+# Contact Page Layout
+
+## Goal
+
+Build the contact page layout shown in `notes/contact-mockup-figma-2.png` (the Figma desktop export) so the contact page renders as a clean two-column 50/50 split: contact info (heading + address/email/phone with CWC Blue 03 outline icons) on the left with a white background and generous padding, and a desaturated CWC Blue 03 Leaflet map on the right that fills to the viewport edge. A Gravity Forms contact form sits between the contact info and the map. The page has no hero — just a ~3rem top margin below the header and no page title.
+
+## Scope
+
+This spec covers two related changes:
+
+1. **The contact page layout** (the primary work) — a new two-column 50/50 layout for the contact page: contact info on the left, Gravity Forms + Leaflet placeholder on the right, no hero, no page title.
+2. **Re-scope the existing inner-page hero** so it renders only on Services children and grandchildren. The current logic in `header.php` (`$showHero = ... hero_style === 'default' ...`) gates the hero per-page via the `hero_style` ACF field; the new logic adds an additional gate: even when `hero_style === 'default'`, the hero is suppressed unless the current page is the Services page, a child of the Services page, or a grandchild of the Services page. All other pages get a plain page body with no hero (matching the contact page's no-hero treatment).
+
+### Contact page scope
+
+- Applies only to the contact page, via the editor dropping the `acf/contact-info` block on the contact page.
+- The existing `acf/contact-info` block is modified to render the new layout (left/contact-info, right/Leaflet placeholder) and drop the `InnerBlocks` call.
+- `page.php` is not changed — non-contact pages still get the inner-page hero (gated per the re-scope) or no hero at all.
+- No hero is rendered on the contact page. The contact page's `hero_style` ACF field should be set to `none` (this is the existing default, already handled by `header.php`).
+- The existing site-header, site-footer, and `hasSidebar()` filter are unchanged. The contact page does not show the sidebar (the contact-info block replaces it).
+- The existing `contact-block` block (the dark-navy CTA banner used on the home page) is unaffected. So is the footer.
+
+### Inner-page hero re-scope
+
+- The hero (already implemented: gradient backdrop, hero image, vector, intro) renders only when ALL of the following are true:
+ - `get_field( 'hero_style' ) === 'default'` (the existing gate), AND
+ - the current page is the Services page, a child of the Services page, or a grandchild of the Services page.
+- "Services page" is identified by slug: a page with `post_name = 'services'`. This is robust to the Services page's ID changing across environments.
+- "Child of the Services page" means `post_parent == $services_id`.
+- "Grandchild of the Services page" means the current page's `post_parent`'s `post_parent == $services_id`. (Two levels only; we do not need a recursive walker because the site's structure is shallow.)
+- A new PHP helper `isServicesDescendant()` lives in `lib/extras.php` next to `hasPageHeader()` and `hasSidebar()`. It returns `true` for the Services page itself, its children, and its grandchildren; `false` otherwise. It accepts the current post (default: `$post`) and an optional services-page override (default: looked up by slug).
+- `header.php` updates the `$showHero` calculation to also require `isServicesDescendant()`. The existing `is_home() || is_archive() || is_single() || is_search() || is_404()` conditions still gate the hero on those views (those views bypass the Services gate).
+- The `Page Heading` ACF group's `hero_style` field is unchanged. The conditional-logic that hides the hero_image and hero_vector fields when `hero_style === 'none'` is unchanged. Editors continue to control the gate per-page; the new check is a global "is this a Services descendant?" filter on top of that.
+- Pages that currently have `hero_style === 'default'` set but are NOT Services descendants lose their hero after this change. This is intentional (per the user's "scope only for Services children/grandchildren" instruction), but the editor may need to set `hero_style = 'none'` on those pages if they don't already have it. A pre-flight check lists the affected pages so the editor can review.
+
+## Architecture
+
+The contact page is composed of three layers:
+
+1. **Page wrapper** — `page.php` continues to call `get_header()` and `get_footer()`. The contact page's body is the new `acf/contact-info` block, which the editor drops onto the page (the existing block). Because the block manages its own layout, no new page template is needed.
+2. **Contact info block** — `views/blocks/contact-info/contact-info.php` is rewritten to render the new two-column 50/50 layout. The left column holds a heading, the address/email/phone rows (from the `contact_info` global option, with CWC Blue 03 outline icons), and a Gravity Forms `[gravityform id="1" title="false"]` shortcode. The right column holds a placeholder `
` for the Leaflet embed.
+3. **Block CSS** — `views/blocks/contact-info/contact-info.css` is rewritten to lay out the two columns, the icon rows, and the Gravity Forms field overrides. Forms.css (`styles/base/forms.css`) already styles most of the form; the new CSS adds the field-level overrides (uppercase CWC Blue 01 labels, right-aligned CWC Orange 01 required asterisks, 30% opacity CWC Blue 03 input fills with borders, taller message textarea, and the secondary-button submit).
+
+`page.php` is untouched. The contact page's editor just adds the `Contact Info` block and the existing flow renders it.
+
+## `isServicesDescendant()` — `lib/extras.php`
+
+```php
+function isServicesDescendant( $post = null, $services_id = null ) {
+ 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 = $services->ID;
+ }
+
+ // The Services page itself.
+ if ( (int) $post->ID === (int) $services_id ) {
+ return true;
+ }
+
+ // Direct child of the Services page.
+ if ( (int) $post->post_parent === (int) $services_id ) {
+ return true;
+ }
+
+ // Grandchild: walk one level up.
+ $parent = get_post( $post->post_parent );
+ if ( $parent && (int) $parent->post_parent === (int) $services_id ) {
+ return true;
+ }
+
+ return false;
+}
+```
+
+`get_page_by_path( 'services' )` is a core WP function that finds a page by slug. It returns `null` if the Services page doesn't exist (e.g. on a fresh dev install), in which case the function returns `false` and no hero renders anywhere — a safe default.
+
+## `header.php` change
+
+The current `$showHero` is:
+
+```php
+$showHero = in_array(
+ true,
+ array(
+ get_field( 'hero_style' ) === 'default',
+ is_home(),
+ is_archive(),
+ is_single(),
+ is_search(),
+ is_404(),
+ ),
+ true
+);
+```
+
+The new `$showHero` is:
+
+```php
+$showHero = in_array(
+ true,
+ array(
+ isServicesDescendant() && get_field( 'hero_style' ) === 'default',
+ is_home(),
+ is_archive(),
+ is_single(),
+ is_search(),
+ is_404(),
+ ),
+ true
+);
+```
+
+The `is_home() / is_archive() / is_single() / is_search() / is_404()` conditions still gate the hero on those views, bypassing the Services check (so blog posts still get the inner-page layout they had before, single posts still get the dark hero from `page-hero.php`, etc.). The Services gate only applies to the `page` post type.
+
+## ACF Field Group Changes
+
+None. The `contact_info` global option group (`acf/group_5fd3e006e5da5.json`) already provides `email`, `phone`, `address`, `fax`, and `hours` sub-fields. We read `email`, `phone`, and `address` only.
+
+The `acf/contact-info` block (`acf/group_5fd3e006e5da5.json`'s consumer, defined by `views/blocks/contact-info/block.json`) currently has no ACF fields of its own — it's an `InnerBlocks` block. We will not add ACF fields. The heading, the shortcode, and the Leaflet placeholder are hard-coded into the block's render template, since they are part of the design, not per-page content.
+
+If the editor later needs to override the heading or shortcode, that becomes a follow-up: add ACF fields to the block, default them to the current hard-coded values, and render the field value with a fallback to the hard-coded string.
+
+## Contact Info Block — `views/blocks/contact-info/contact-info.php`
+
+The block renders a `