# 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 `<div id="contact-map">` 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 = isServicesDescendant() && get_field( 'hero_style' ) === 'default';
```

The hero renders only on the Services page, its direct children, and its grandchildren — and only when the editor has set `hero_style` to `'default'`. The blog index, archives, single posts, search, and 404 views do **not** render the inner-page hero at all. Those views rely on their own templates (`front-page.php`, `single.php`, `search.php`, `404.php`, `archive.php`) for any visual treatment.

> **Note:** an earlier draft of this spec kept `is_home() / is_archive() / is_single() / is_search() / is_404()` in the bypass list so blog posts and single posts kept their old hero behavior. The user revised this on 2026-07-04 after seeing the live result: the Services hero treatment should not apply to any non-Services view. The change is captured in commits `2fb1d67` (header.php) and `ee9c9ce` (test update).

## 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 `<section class="contact-info">` with a flex row that flips to a column on mobile. The block manages its own width — it does NOT inherit the `.container` constraint from `page.php`'s `.entry-content`. To achieve this, the block's outermost wrapper gets `container` itself and resets the page's `entry-content` padding so the map can reach the viewport edge on the right.

```php
<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">
                <li class="contact-info__item contact-info__item--address">
                    <span class="contact-info__icon" aria-hidden="true">[icon-address]</span>
                    <span class="contact-info__value"><?php echo wp_kses_post( get_field( 'contact_info', 'option' )['address'] ); ?></span>
                </li>
                <li class="contact-info__item contact-info__item--email">
                    <span class="contact-info__icon" aria-hidden="true">[icon-email]</span>
                    <a class="contact-info__value" href="mailto:<?php echo esc_attr( get_field( 'contact_info', 'option' )['email'] ); ?>">
                        <?php echo esc_html( get_field( 'contact_info', 'option' )['email'] ); ?>
                    </a>
                </li>
                <li class="contact-info__item contact-info__item--phone">
                    <span class="contact-info__icon" aria-hidden="true">[icon-phone]</span>
                    <a class="contact-info__value" href="tel:<?php echo esc_attr( get_field( 'contact_info', 'option' )['phone'] ); ?>">
                        <?php echo esc_html( get_field( 'contact_info', 'option' )['phone'] ); ?>
                    </a>
                </li>
            </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>
```

The `[icon-address]`, `[icon-email]`, `[icon-phone]` placeholders are inlined SVGs with `stroke="currentColor"` and `fill="none"`, sized to the CWC Blue 03 color via CSS `color: var(--color-cwc-blue-03)`. The three SVGs are stored in `static/img/contact/` and referenced via `get_theme_file_uri()` (the same pattern the existing `static/img/footer-email.svg` etc. use).

## Contact Info Block CSS — `views/blocks/contact-info/contact-info.css`

Rules (mobile-first, desktop via `@media (min-width: 1024px)`):

- `.contact-info` — `position: relative; background: var(--color-white); padding-block: clamp(2rem, 6vw, 4rem);`. Resets the `.entry-content` container.
- `.contact-info__grid` — on mobile, single column. On `≥1024px`, `display: grid; grid-template-columns: 1fr 1fr; gap: 0;`. The right column extends to the viewport edge via negative right margin matching the page's container offset (the same trick the homepage-hero and the contact-block use to break out of the container on one side).
- `.contact-info__details` — `padding-inline: clamp(1.5rem, 5vw, 3rem); max-width: 36rem;` so the heading, address, and form are aligned with the logo's left edge and don't span the full half.
- `.contact-info__heading` — `font-family: var(--font-quincy, 'Quincy', serif); font-weight: 400; font-size: clamp(2.5rem, 6vw, 4.5rem); line-height: 1.05; color: var(--color-cwc-blue-01); margin-bottom: 1.5rem;`. Matches the inner-page hero heading for visual consistency.
- `.contact-info__items` — `list-style: none; padding: 0; margin: 0 0 2rem 0; display: flex; flex-direction: column; gap: 0.75rem;`.
- `.contact-info__item` — `display: flex; align-items: flex-start; gap: 0.75rem; color: var(--color-dark); font-size: 1rem; line-height: 1.5;`.
- `.contact-info__icon` — `display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; flex-shrink: 0; color: var(--color-cwc-blue-03);`. Icons inherit the color via `currentColor`.
- `.contact-info__value` — `text-decoration: none; color: inherit; &:hover { color: var(--color-secondary); }`.
- `.contact-info__form` — Gravity Forms wrapper. Adds a top margin (`mt-8`) and the form-field overrides below.
- `.contact-info__map` — `background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white); min-height: 24rem; position: relative;`. On `≥1024px`, breaks out to the viewport's right edge by setting `margin-right: calc(50% - 50vw); width: calc(50vw + 0px);`. The right edge is anchored to `100vw`, not the parent grid, so the map fills the right half of the screen on any desktop width.
- Mobile (≤767px): `.contact-info__grid` collapses to one column, the map drops below the form, the right-edge break-out is removed.

### Gravity Forms field overrides (in `contact-info.css`)

Append after the layout rules:

- `.contact-info__form .gfield_label` — `text-transform: uppercase; color: var(--color-cwc-blue-01); font-size: 0.875rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0.5rem;`.
- `.contact-info__form .gfield_required` — `color: var(--color-cwc-orange-01); margin-left: 0.25rem; font-weight: 700;`. Hide the default asterisk text and use an `::after` pseudo-element with content `" *"` so the asterisk is right-aligned inside the label (the default `gfield_required` is a trailing span; we override the label's flex layout to push the asterisk to the right with `display: flex; justify-content: space-between;` and put the `::after` on the label itself).
- `.contact-info__form input[type="text"], .contact-info__form input[type="email"], .contact-info__form textarea` — `background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white); border: 1px solid var(--color-cwc-blue-03); border-radius: 0.375rem; padding: 0.75rem 1rem; font-size: 1rem;`. Override the existing `border-2 border-primary` from `forms.css` (this block has its own scope, so cascade works).
- `.contact-info__form textarea` — `min-height: 10rem;` so the message field is "substantially taller" than the name/email inputs.
- `.contact-info__form .gform_footer input[type="submit"], .contact-info__form .gform_footer button[type="submit"]` — same visual treatment as the header's Contact button: `class="button dark"` is added by the Gravity Forms markup if we set `gforms form settings → button class` to `button dark`; otherwise we apply `class="button dark"` via jQuery or a CSS rule that targets `.gform_footer input[type="submit"]` and forces the same look. The button gets the secondary-button gradient: `background: var(--background-image-button-gradient); color: var(--color-white); border: 3px solid var(--color-secondary); border-radius: 1.25rem 0.25rem 1.25rem 0.25rem; padding: 0.625rem 2rem; font-weight: 700;`. This matches the existing header Contact button shape.

## Static Assets

Three new SVG icons in `static/img/contact/`:

- `icon-address.svg` — map pin outline, 24×24 viewBox, `stroke="currentColor" fill="none" stroke-width="1.5"`.
- `icon-email.svg` — envelope outline, 24×24, same stroke.
- `icon-phone.svg` — phone outline, 24×24, same stroke.

The SVGs are sourced from the user (the spec says "will provide SVGs"). Until they arrive, the block renders empty `.contact-info__icon` spans so the layout is correct but the icons are blank — and the test suite can still verify the layout.

The Leaflet map pin (orange-01 + white) and the actual Leaflet embed code are not yet available. The right column is a styled placeholder with a comment indicating where Leaflet will mount.

## Data Flow

- **Editor flow**: on the contact page, the editor adds the existing `Contact Info` block. The block renders the static layout. The block has no per-instance fields; everything comes from the global `contact_info` option.
- **Render flow**: `page.php` runs `the_content()` → the editor's `Contact Info` block renders → the block reads the global `contact_info` for address/email/phone → the block renders the Gravity Forms shortcode and the Leaflet placeholder.
- **Gravity Forms shortcode**: `[gravityform id="1" title="false"]` (per the user). The `title="false"` and `description="false"` are added by the block's render template to suppress the form's title/description and let the block's own heading carry that role.

## Error Handling and Edge Cases

- `contact_info.address` empty → the address `<li>` still renders but the value span is empty. Acceptable for a placeholder; the editor should fill the global field.
- `contact_info.email` empty → the email `<li>` is omitted (wraps the value in `if ( $email )`).
- `contact_info.phone` empty → same, omit.
- Gravity Form ID 1 not found → the `[gravityform]` shortcode returns an empty string. The form area shows an empty box. The editor sees a visible gap, which is the right signal to fix the form.
- Leaflet embed not present → the right column is a gray placeholder. Layout is correct; the editor knows to drop in the embed.
- Editor adds the `Contact Info` block to a non-contact page → the block renders the same layout. This is intentional; the block is reusable. Pages that need a different layout (e.g. a 404, a privacy policy) should not add this block.
- Existing pages that use the `Contact Info` block with `InnerBlocks` (if any) lose the InnerBlocks area. A repo-wide search shows no such pages exist, but the change is breaking for any page that was using the block as a wrapper for other blocks. This is a known follow-up: audit all pages using `Contact Info` before merging.

## Out of Scope

- A new page template (`template-contact.php`). The block is the page.
- A new ACF field group for the contact page.
- Per-page override of the heading, the shortcode, or the icon assets.
- The Leaflet embed itself (placeholder only).
- The map pin SVG.
- An audit of all existing pages using the `Contact Info` block (manual follow-up, see Error Handling).
- Changes to the header, footer, or `page.php`.
- The inner-page hero (already done, gated by `hero_style = 'default'`).

## Verification

- Add `tests/contact-page.spec.js` covering:
  - Desktop (1280px): the contact page renders with two columns; the left column has the heading, the address/email/phone rows (with icon spans), the Gravity Forms shortcode renders (or, if the form doesn't exist on the test env, the form area is empty — the test asserts the `.contact-info__form` element exists, not its contents), and the right column is the Leaflet placeholder.
  - Mobile (402px): single column; the map drops below the form; no horizontal overflow.
  - 320px: nothing clips; all text remains inside the viewport.
  - Axe scan: no violations.
  - Console errors: none.
- Add `tests/services-hero-scope.spec.js` covering the re-scope:
  - The Services page itself renders the hero (assuming the fixture has `hero_style = 'default'`).
  - A child of the Services page (e.g. Communication Services) renders the hero.
  - A grandchild of the Services page renders the hero.
  - A page that is NOT a Services descendant (e.g. About, Contact) does NOT render the hero, even if its `hero_style = 'default'`.
  - A blog post, archive, search, or 404 still renders the hero (these views bypass the Services gate).
- Use the existing contact page fixture (or seed one in `beforeAll` if no fixture exists, and tear down in `afterAll`).
- Run `npm run build` to regenerate `static/dist/`.
- Run the full Playwright suite (`npx playwright test`) including `tests/mobile-homepage.spec.js`, `tests/site-a11y.spec.js`, `tests/inner-page.spec.js`, and the two new specs to confirm no regressions.
- Run `composer lint` to catch PHPCS issues.
- Capture 1280px and 402px full-page screenshots for visual comparison with the mockup.

## Acceptance Criteria

1. The contact page (`/contact/`) renders the new two-column 50/50 layout.
2. The left column has the heading, address, email, and phone (from the global `contact_info` option), each with a CWC Blue 03 outline icon to the left.
3. The Gravity Forms shortcode `[gravityform id="1" title="false"]` renders below the address items, with custom field styling: uppercase CWC Blue 01 labels, CWC Orange 01 right-aligned required asterisks, 30% opacity CWC Blue 03 input fills with CWC Blue 03 borders, slightly rounded corners, comfortable padding, a substantially taller message textarea, and a submit button matching the header's Contact button.
4. The right column is a placeholder for the Leaflet map, fills 50% of the viewport width on desktop (edge to edge), and drops below the form on mobile.
5. No hero is rendered on the contact page. There is a ~3rem top margin below the header and no page title above the body.
6. The inner-page hero renders on the Services page, on its direct children, and on its grandchildren — and ONLY on those pages. Pages that have `hero_style = 'default'` but are not Services descendants lose their hero. The blog index, archives, single posts, search, and 404 also do **not** render the inner-page hero.
7. No new console errors, axe violations, or PHPCS errors.
8. Existing pages that are Services descendants render with the inner-page hero; all other pages (blog, archives, single posts, search, 404, non-Services `page` posts) render without it.
9. Existing tests (`mobile-homepage`, `site-a11y`, `inner-page`) continue to pass.
10. The block is reusable: adding the `Contact Info` block to another page renders the same layout.
