Files
CWC/docs/superpowers/specs/2026-07-04-contact-page-design.md

22 KiB
Raw Permalink Blame History

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 wrapperpage.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 blockviews/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 CSSviews/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

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 = 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.

<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-infoposition: 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__detailspadding-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__headingfont-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__itemslist-style: none; padding: 0; margin: 0 0 2rem 0; display: flex; flex-direction: column; gap: 0.75rem;.
  • .contact-info__itemdisplay: flex; align-items: flex-start; gap: 0.75rem; color: var(--color-dark); font-size: 1rem; line-height: 1.5;.
  • .contact-info__icondisplay: 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__valuetext-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__mapbackground: 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_labeltext-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_requiredcolor: 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 textareabackground: 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 textareamin-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.