22 KiB
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:
- 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.
- 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 thehero_styleACF field; the new logic adds an additional gate: even whenhero_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-infoblock on the contact page. - The existing
acf/contact-infoblock is modified to render the new layout (left/contact-info, right/Leaflet placeholder) and drop theInnerBlockscall. page.phpis 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_styleACF field should be set tonone(this is the existing default, already handled byheader.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-blockblock (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'spost_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 inlib/extras.phpnext tohasPageHeader()andhasSidebar(). It returnstruefor the Services page itself, its children, and its grandchildren;falseotherwise. It accepts the current post (default:$post) and an optional services-page override (default: looked up by slug). header.phpupdates the$showHerocalculation to also requireisServicesDescendant(). The existingis_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 HeadingACF group'shero_stylefield is unchanged. The conditional-logic that hides the hero_image and hero_vector fields whenhero_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 sethero_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:
- Page wrapper —
page.phpcontinues to callget_header()andget_footer(). The contact page's body is the newacf/contact-infoblock, which the editor drops onto the page (the existing block). Because the block manages its own layout, no new page template is needed. - Contact info block —
views/blocks/contact-info/contact-info.phpis rewritten to render the new two-column 50/50 layout. The left column holds a heading, the address/email/phone rows (from thecontact_infoglobal 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. - Block CSS —
views/blocks/contact-info/contact-info.cssis 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
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:
$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:
$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 <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.
<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-contentcontainer..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 viacurrentColor..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 settingmargin-right: calc(50% - 50vw); width: calc(50vw + 0px);. The right edge is anchored to100vw, not the parent grid, so the map fills the right half of the screen on any desktop width.- Mobile (≤767px):
.contact-info__gridcollapses 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::afterpseudo-element with content" *"so the asterisk is right-aligned inside the label (the defaultgfield_requiredis a trailing span; we override the label's flex layout to push the asterisk to the right withdisplay: flex; justify-content: space-between;and put the::afteron 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 existingborder-2 border-primaryfromforms.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 setgforms form settings → button classtobutton dark; otherwise we applyclass="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 Infoblock. The block renders the static layout. The block has no per-instance fields; everything comes from the globalcontact_infooption. - Render flow:
page.phprunsthe_content()→ the editor'sContact Infoblock renders → the block reads the globalcontact_infofor 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). Thetitle="false"anddescription="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.addressempty → the address<li>still renders but the value span is empty. Acceptable for a placeholder; the editor should fill the global field.contact_info.emailempty → the email<li>is omitted (wraps the value inif ( $email )).contact_info.phoneempty → 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 Infoblock 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 Infoblock withInnerBlocks(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 usingContact Infobefore 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 Infoblock (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.jscovering:- 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__formelement 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.
- 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
- Add
tests/services-hero-scope.spec.jscovering 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).
- The Services page itself renders the hero (assuming the fixture has
- Use the existing contact page fixture (or seed one in
beforeAllif no fixture exists, and tear down inafterAll). - Run
npm run buildto regeneratestatic/dist/. - Run the full Playwright suite (
npx playwright test) includingtests/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 lintto catch PHPCS issues. - Capture 1280px and 402px full-page screenshots for visual comparison with the mockup.
Acceptance Criteria
- The contact page (
/contact/) renders the new two-column 50/50 layout. - The left column has the heading, address, email, and phone (from the global
contact_infooption), each with a CWC Blue 03 outline icon to the left. - 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. - 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.
- No hero is rendered on the contact page. There is a ~3rem top margin below the header and no page title above the body.
- 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. - Blog posts, archives, search, and 404 still render their existing heroes (the Services gate does not apply to those views).
- No new console errors, axe violations, or PHPCS errors.
- Existing pages (Services children/grandchildren with the inner-page hero, single posts, archives, search, 404) render unchanged.
- Existing tests (
mobile-homepage,site-a11y,inner-page) continue to pass. - The block is reusable: adding the
Contact Infoblock to another page renders the same layout.