# 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 = 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 `
` 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
>

Contact

``` 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 `
  • ` 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 `
  • ` 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.