Files
CWC/docs/superpowers/specs/2026-07-04-contact-page-design.md
T
Keith Solomon 01c6fb7831
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
feature: Set up contact info block and map
2026-07-04 19:22:26 -05:00

22 KiB
Raw 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 = 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-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.
  7. Blog posts, archives, search, and 404 still render their existing heroes (the Services gate does not apply to those views).
  8. No new console errors, axe violations, or PHPCS errors.
  9. Existing pages (Services children/grandchildren with the inner-page hero, single posts, archives, search, 404) render unchanged.
  10. Existing tests (mobile-homepage, site-a11y, inner-page) continue to pass.
  11. The block is reusable: adding the Contact Info block to another page renders the same layout.