265 lines
22 KiB
Markdown
265 lines
22 KiB
Markdown
# 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.
|