Compare commits
19
Commits
b53f7bb4ca
...
dddb4b8c54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dddb4b8c54 | ||
|
|
6452f28932 | ||
|
|
4aa6ff694d | ||
|
|
2bda7f21eb | ||
|
|
ec87cae6c5 | ||
|
|
e221f62ef7 | ||
|
|
342ae015c7 | ||
|
|
667cb2ec89 | ||
|
|
8b7819e932 | ||
|
|
998619fa3f | ||
|
|
cda3878169 | ||
|
|
edf3d6c5ce | ||
|
|
d389fd7bc8 | ||
|
|
a8affd474d | ||
|
|
ffdf79ae01 | ||
|
|
145df12149 | ||
|
|
2e0b4225a1 | ||
|
|
028fcf28c1 | ||
|
|
455a005e58 |
@@ -161,6 +161,74 @@
|
|||||||
"parent_repeater": "field_60bfb8614a421"
|
"parent_repeater": "field_60bfb8614a421"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_68new01heroimage",
|
||||||
|
"label": "Hero Image",
|
||||||
|
"name": "hero_image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "Optional background image for the page hero.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "field_60bfda53dc0f2",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"return_format": "array",
|
||||||
|
"library": "all",
|
||||||
|
"min_width": "",
|
||||||
|
"min_height": "",
|
||||||
|
"min_size": "",
|
||||||
|
"max_width": "",
|
||||||
|
"max_height": "",
|
||||||
|
"max_size": "",
|
||||||
|
"mime_types": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"preview_size": "medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_68new02herovector",
|
||||||
|
"label": "Hero Vector",
|
||||||
|
"name": "hero_vector",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "Optional decorative linework/vector rendered on the right side of the hero.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "field_60bfda53dc0f2",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"return_format": "array",
|
||||||
|
"library": "all",
|
||||||
|
"min_width": "",
|
||||||
|
"min_height": "",
|
||||||
|
"min_size": "",
|
||||||
|
"max_width": "",
|
||||||
|
"max_height": "",
|
||||||
|
"max_size": "",
|
||||||
|
"mime_types": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"preview_size": "medium"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"location": [
|
"location": [
|
||||||
@@ -188,5 +256,5 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"description": "",
|
"description": "",
|
||||||
"show_in_rest": 0,
|
"show_in_rest": 0,
|
||||||
"modified": 1746895738
|
"modified": 1782918111
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,757 @@
|
|||||||
|
# Inner Page Layout Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the inner page layout from `notes/inner-page-mockup.png` so every WordPress page renders a dark hero (with background image and decorative vector on the right), a centered narrow intro section, and the standard editor body — with per-page variation only in hero image, vector, intro, and body content.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing `Page Heading` ACF field group with two new fields (`hero_image`, `hero_vector`). Modify the existing `views/partials/page-hero.php` to render the new image/vector and to stop rendering the `intro` paragraph inside the hero. Add a new `views/partials/page-intro.php` partial. Wire it into `views/page.php` between the hero and `the_content`. Add scoped CSS for the new hero media layers.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress (PHP), ACF Pro, Tailwind v4 (built via `npm run build`), Playwright, axe-core.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Theme root: `wp-content/themes/community-works-collaborative/`
|
||||||
|
- Build command: `npm run build` (Tailwind v4 → `static/dist/theme.css`)
|
||||||
|
- Lint: `composer lint`
|
||||||
|
- Tests: `npx playwright test`
|
||||||
|
- Dev site: `http://community-works-collaborative.test/`
|
||||||
|
- Existing test pattern: `tests/mobile-homepage.spec.js:6-14` (`getBox`, `expectNear` helpers; `await expect(...).toBeVisible()` before reading dimensions of lazy-loaded images)
|
||||||
|
- The hero is loaded via `header.php:60` via `get_template_part( 'views/partials/page-hero' )`. Don't move that call.
|
||||||
|
- ACF JSON: add the two new fields by editing `acf/group_60bfb84ae973c.json` (this is the file ACF loads from). New fields keep the same `conditional_logic` rule as the existing `heading` and `intro` fields: shown when `hero_style !== 'none'`.
|
||||||
|
- Per-component CSS for the hero media layers goes in a new `styles/blocks/page-hero.css` and is imported from `styles/blocks/index.css`. Scoping is enforced with a `.page-hero` class root.
|
||||||
|
- Page slug for the dev fixture: `sample-page` (per `content/basic-wp-test-content.xml:7406`). Full URL: `http://community-works-collaborative.test/sample-page/`
|
||||||
|
- No new ACF blocks. No new dependencies. No front-page / single / archive / search / 404 changes. The mockup's bottom dark block is the existing site footer — do not add a new contact CTA to the page body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
- `wp-content/themes/community-works-collaborative/views/partials/page-intro.php` — Renders the centered narrow intro column from the existing `intro` ACF field. Tailwind utility classes only; no CSS file needed.
|
||||||
|
- `wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css` — Scoped styles for the new hero image/vector media layers (`.page-hero__media`, `.page-hero__vector`, `.page-hero__content`) including the mobile breakpoint at 767px.
|
||||||
|
- `wp-content/themes/community-works-collaborative/tests/inner-page.spec.js` — Playwright tests for the inner page layout at desktop, 402px, 320px, 767px, plus no-intro and no-asset fallback cases.
|
||||||
|
|
||||||
|
### Modify
|
||||||
|
|
||||||
|
- `wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json` — Add `hero_image` and `hero_vector` image fields. Bump `modified` timestamp.
|
||||||
|
- `wp-content/themes/community-works-collaborative/styles/blocks/index.css` — Add `@import './page-hero.css';`.
|
||||||
|
- `wp-content/themes/community-works-collaborative/views/partials/page-hero.php` — Read the two new fields. Render `hero_image` and `hero_vector` as absolutely-positioned media layers. Add `.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content` classes. Stop rendering the in-hero `intro` paragraph.
|
||||||
|
- `wp-content/themes/community-works-collaborative/page.php` — Render the page-intro partial between the hero and `the_content` when the `intro` field is non-empty. (Note: `page.php` is at the theme root, not `views/page.php` — WordPress convention. Use tabs to match the existing file and the WordPress coding standard enforced by phpcs.)
|
||||||
|
|
||||||
|
### Touched for verification only (no content changes)
|
||||||
|
|
||||||
|
- `package.json` — Build script is `npm run build`. No change.
|
||||||
|
- `static/dist/theme.css` — Regenerated by `npm run build`. Not edited by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add the ACF fields
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: Two new fields readable via `get_field( 'hero_image' )` and `get_field( 'hero_vector' )` in the page-hero partial. Both return arrays with at least `['url']` and `['alt']` keys when set, or `false` when empty.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current ACF JSON**
|
||||||
|
|
||||||
|
Read `acf/group_60bfb84ae973c.json` to confirm the current shape.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the two new field definitions**
|
||||||
|
|
||||||
|
Insert the following two field objects into the `fields` array, **after** the `call_to_actions` field (the last existing field). Each new field mirrors the `conditional_logic` pattern used by `heading` and `intro` (show when `hero_style !== 'none'`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
,
|
||||||
|
{
|
||||||
|
"key": "field_68new01heroimage",
|
||||||
|
"label": "Hero Image",
|
||||||
|
"name": "hero_image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "Optional background image for the page hero.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "field_60bfda53dc0f2",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"return_format": "array",
|
||||||
|
"library": "all",
|
||||||
|
"min_width": "",
|
||||||
|
"min_height": "",
|
||||||
|
"min_size": "",
|
||||||
|
"max_width": "",
|
||||||
|
"max_height": "",
|
||||||
|
"max_size": "",
|
||||||
|
"mime_types": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"preview_size": "medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_68new02herovector",
|
||||||
|
"label": "Hero Vector",
|
||||||
|
"name": "hero_vector",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "Optional decorative linework/vector rendered on the right side of the hero.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "field_60bfda53dc0f2",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"return_format": "array",
|
||||||
|
"library": "all",
|
||||||
|
"min_width": "",
|
||||||
|
"min_height": "",
|
||||||
|
"min_size": "",
|
||||||
|
"max_width": "",
|
||||||
|
"max_height": "",
|
||||||
|
"max_size": "",
|
||||||
|
"mime_types": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"preview_size": "medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bump the `modified` timestamp**
|
||||||
|
|
||||||
|
Change the top-level `"modified": 1746895738` value to a fresh Unix timestamp. Use `date +%s` (or, on Windows PowerShell: `[int][double]::Parse((Get-Date -UFormat %s))`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate the JSON**
|
||||||
|
|
||||||
|
Run: `cat acf/group_60bfb84ae973c.json | node -e "JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('valid')"`
|
||||||
|
Expected: `valid`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add acf/group_60bfb84ae973c.json
|
||||||
|
git commit -m "Add hero_image and hero_vector fields to Page Heading group"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create the page-intro partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wp-content/themes/community-works-collaborative/views/partials/page-intro.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `get_field( 'intro' )` — a non-empty string when the page has an intro.
|
||||||
|
- Produces: An HTML `<section class="page-intro ...">` block with the intro text wrapped in a `<p>`. No new CSS file; uses Tailwind utilities only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the partial file**
|
||||||
|
|
||||||
|
Write `views/partials/page-intro.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Page Intro Partial
|
||||||
|
*
|
||||||
|
* Renders the centered narrow intro section beneath the page hero.
|
||||||
|
* Loaded by page.php when the `intro` ACF field is non-empty.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$intro = get_field( 'intro' );
|
||||||
|
|
||||||
|
if ( ! $intro ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="page-intro py-12 lg:py-20 text-center" aria-label="<?php echo esc_attr__( 'Page introduction' ); ?>">
|
||||||
|
<div class="mx-auto max-w-3xl content-wrapper">
|
||||||
|
<p class="page-intro__text text-lg lg:text-xl leading-relaxed text-dark">
|
||||||
|
<?php echo wp_kses_post( $intro ); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the inner `div` intentionally does **not** include the `container` Tailwind class. The project's `styles/base/global.css` defines `.container { max-width: 80.5rem; }` (1288px), which is loaded after the Tailwind utilities in the cascade, so it would override `max-w-3xl` (768px) at desktop and produce an over-wide intro. Use only `mx-auto max-w-3xl` so the cap applies.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint the new file**
|
||||||
|
|
||||||
|
Run: `composer lint -- views/partials/page-intro.php`
|
||||||
|
Expected: no errors. If PHPCS reports style issues, fix the file and re-run until clean.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add views/partials/page-intro.php
|
||||||
|
git commit -m "Add page-intro partial for centered narrow intro section"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Wire the intro partial into page.php
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wp-content/themes/community-works-collaborative/page.php` (at the theme root, not `views/page.php` — WordPress convention)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The `page-intro` partial (Task 2). Reads `get_field( 'intro' )`.
|
||||||
|
- Produces: When the `intro` field is set, the page-intro partial renders between the hero (loaded by `header.php`) and `the_content`. When empty, only the body renders.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current page.php**
|
||||||
|
|
||||||
|
Read `page.php` to confirm current contents. Note the indentation style — the existing file uses **tabs** (WordPress coding standard). The brief's code block uses 4-space indentation as a transcription convention; convert to tabs to match the surrounding code.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace page.php with the new version**
|
||||||
|
|
||||||
|
Write `page.php` to match (using **tabs** for indentation):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Single Pages
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
get_header();
|
||||||
|
|
||||||
|
$clsEntry = '';
|
||||||
|
|
||||||
|
// Determine classes based on sidebar presence
|
||||||
|
if ( hasSidebar() ) {
|
||||||
|
$classes = 'container grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-16 my-section mx-auto';
|
||||||
|
$clsEntry = 'lg:col-span-3';
|
||||||
|
} else {
|
||||||
|
$classes = 'container my-section lg:my-0 mx-auto';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<article class="<?php echo esc_attr( $classes ); ?>">
|
||||||
|
<?php
|
||||||
|
// Centered intro section between the hero and the body content.
|
||||||
|
if ( get_field( 'intro' ) ) {
|
||||||
|
get_template_part( 'views/partials/page-intro' );
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
|
||||||
|
<?php
|
||||||
|
// Page body content
|
||||||
|
if ( have_posts() ) {
|
||||||
|
while ( have_posts() ) {
|
||||||
|
the_post();
|
||||||
|
the_content();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ( hasSidebar() ) : ?>
|
||||||
|
<?php get_sidebar( 'page' ); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<?php get_footer(); ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the page-intro partial is loaded as a **sibling** of `<div class="entry-content">`, not inside it. When intro is a child of entry-content, the two bounding boxes share the same top y, so the test's `expect(introBox.y).toBeLessThan(contentBox.y)` order assertion fails. Moving intro out of entry-content makes the order check hold.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint the modified file**
|
||||||
|
|
||||||
|
Run: `vendor/bin/phpcs page.php`
|
||||||
|
Expected: 1/1 clean. The pre-existing CRLF EOL errors in unrelated files (per Task 2's report) are out of scope; don't run `composer lint` (full project) for this task.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add page.php
|
||||||
|
git commit -m "Render page-intro partial between hero and body in page.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add scoped CSS for the new hero media layers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css`
|
||||||
|
- Modify: `wp-content/themes/community-works-collaborative/styles/blocks/index.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: Three classes (`.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content`) plus a 767px mobile breakpoint. Used by the page-hero partial in Task 5.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the page-hero CSS file**
|
||||||
|
|
||||||
|
Write `styles/blocks/page-hero.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Page Hero partial styles (page-hero.php)
|
||||||
|
* Scoped to .page-hero to avoid affecting the homepage-hero block. */
|
||||||
|
|
||||||
|
.page-hero {
|
||||||
|
isolation: isolate;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__media {
|
||||||
|
inset: 0;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 30%);
|
||||||
|
mask-image: linear-gradient(to right, transparent 0%, black 30%);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__media img {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__vector {
|
||||||
|
bottom: 0;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
width: clamp(18rem, 38vw, 32rem);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page-hero__media {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(0 0 0 / 0.9) 0%,
|
||||||
|
rgb(0 0 0 / 0.45) 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(0 0 0 / 0.9) 0%,
|
||||||
|
rgb(0 0 0 / 0.45) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__vector {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Import the new file from the blocks index**
|
||||||
|
|
||||||
|
Edit `styles/blocks/index.css`. After the line `@import './misc.css';` add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import './page-hero.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
The full file becomes:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Theme block styles */
|
||||||
|
|
||||||
|
@import './buttons.css';
|
||||||
|
@import './core.css';
|
||||||
|
@import './misc.css';
|
||||||
|
@import './page-hero.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the CSS**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: a fresh `static/dist/theme.css` is generated. The output should report a runtime in milliseconds. No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the new classes appear in the built CSS**
|
||||||
|
|
||||||
|
Run: `grep -c "page-hero__media" static/dist/theme.css`
|
||||||
|
Expected: `1` or more.
|
||||||
|
|
||||||
|
Run: `grep -c "page-hero__vector" static/dist/theme.css`
|
||||||
|
Expected: `1` or more.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add styles/blocks/page-hero.css styles/blocks/index.css static/dist/theme.css
|
||||||
|
git commit -m "Add page-hero media layer CSS and import into blocks bundle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update page-hero.php to render image and vector and drop the in-hero intro
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wp-content/themes/community-works-collaborative/views/partials/page-hero.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `get_field( 'hero_image' )` and `get_field( 'hero_vector' )` from the ACF group updated in Task 1. Existing `background_color`, `is_dark`, `heading` fields unchanged.
|
||||||
|
- Produces: A `.page-hero` section with `.page-hero__media` (when `hero_image` is set) and `.page-hero__vector` (when `hero_vector` is set) absolutely-positioned behind a `.page-hero__content` layer. The in-hero intro paragraph is no longer rendered here.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current page-hero.php**
|
||||||
|
|
||||||
|
Read `views/partials/page-hero.php` to confirm current contents.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace page-hero.php with the new version**
|
||||||
|
|
||||||
|
Write `views/partials/page-hero.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Page Hero Partial
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
// Set variables
|
||||||
|
$bgColor = get_field( 'background_color' );
|
||||||
|
$isDark = get_field( 'is_dark' );
|
||||||
|
$heading = get_field( 'heading' );
|
||||||
|
$heroImage = get_field( 'hero_image' );
|
||||||
|
$heroVector = get_field( 'hero_vector' );
|
||||||
|
|
||||||
|
// Fallback for heading
|
||||||
|
if ( ! $heading ) {
|
||||||
|
$heading = getTheTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional logic for dark mode (if needed)
|
||||||
|
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
|
||||||
|
$isDark = true;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-hero bg-cover bg-no-repeat mb-12 py-12 lg:py-16 bg-dark text-light overflow-hidden <?php echo $isDark ? 'dark' : ''; ?>" <?php echo $bgColor ? 'style="background-color: ' . esc_attr( $bgColor ) . '"' : ''; ?>>
|
||||||
|
|
||||||
|
<?php if ( $heroImage ) : ?>
|
||||||
|
<div class="page-hero__media" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
src="<?php echo esc_url( $heroImage['url'] ); ?>"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( $heroVector ) : ?>
|
||||||
|
<img
|
||||||
|
class="page-hero__vector"
|
||||||
|
src="<?php echo esc_url( $heroVector['url'] ); ?>"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="page-hero__content container mx-auto">
|
||||||
|
<div id="breadcrumbs">
|
||||||
|
<?php Breadcrumbs::render(); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:text-center lg:items-start lg:text-left content-wrapper">
|
||||||
|
<?php
|
||||||
|
// Heading
|
||||||
|
if ( apply_filters( 'include_page_title_in_hero', true ) ) {
|
||||||
|
echo '<h1 class="mx-auto text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
|
||||||
|
echo wp_kses_post( $heading );
|
||||||
|
echo '</h1>';
|
||||||
|
} else {
|
||||||
|
echo '<span class="mx-auto block text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
|
||||||
|
echo wp_kses_post( $heading );
|
||||||
|
echo '</span>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the in-hero `<p>` block for the `intro` field is removed. The intro now renders from `page-intro.php` in `page.php`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint the modified file**
|
||||||
|
|
||||||
|
Run: `composer lint -- views/partials/page-hero.php`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add views/partials/page-hero.php
|
||||||
|
git commit -m "Render hero_image and hero_vector in page-hero partial; drop in-hero intro"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Write the Playwright test for the inner page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wp-content/themes/community-works-collaborative/tests/inner-page.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The test page `http://community-works-collaborative.test/sample-page/` (per `content/basic-wp-test-content.xml:7406`). The page-intro partial (Task 2). The page-hero partial with new media layers (Task 5). The CSS file from Task 4.
|
||||||
|
- Produces: A Playwright test file that asserts:
|
||||||
|
- At desktop (1280px): page-hero, page-intro, body all render in that order. Heading and intro visible.
|
||||||
|
- At 402px: same order, vector fills lower portion of hero, intro is centered and within the 48rem (768px) max-width, no clipped text.
|
||||||
|
- At 320px: nothing clips.
|
||||||
|
- At 767px: vector scales proportionally, no clipping.
|
||||||
|
- The page renders without console errors (a final `page.on('pageerror')` listener captures any).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the test file**
|
||||||
|
|
||||||
|
Write `tests/inner-page.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const pageUrl =
|
||||||
|
process.env.TEST_PAGE_URL ||
|
||||||
|
"http://community-works-collaborative.test/sample-page/";
|
||||||
|
|
||||||
|
const getBox = async (locator) => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
return box;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectNear = (actual, expected, tolerance = 3) => {
|
||||||
|
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("inner page layout", () => {
|
||||||
|
test("desktop: hero, intro, body render in order with the new media layers", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-intro").first();
|
||||||
|
const content = page.locator(".entry-content").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
|
||||||
|
// Order check: hero, then intro, then content.
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
const contentBox = await getBox(content);
|
||||||
|
|
||||||
|
expect(heroBox.y).toBeLessThan(introBox.y);
|
||||||
|
expect(introBox.y).toBeLessThan(contentBox.y);
|
||||||
|
|
||||||
|
// Heading is rendered inside the hero.
|
||||||
|
await expect(hero.locator("h1").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Intro is centered and capped at the max-w-3xl width.
|
||||||
|
const introInner = intro.locator(".content-wrapper").first();
|
||||||
|
const introInnerBox = await getBox(introInner);
|
||||||
|
expect(introInnerBox.width).toBeLessThanOrEqual(768 + 3);
|
||||||
|
|
||||||
|
// Page renders without JS errors.
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 402: hero, intro, body order and intro width", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 402, height: 874 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-intro").first();
|
||||||
|
const content = page.locator(".entry-content").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
const contentBox = await getBox(content);
|
||||||
|
|
||||||
|
expect(heroBox.y).toBeLessThan(introBox.y);
|
||||||
|
expect(introBox.y).toBeLessThan(contentBox.y);
|
||||||
|
|
||||||
|
// Intro fits within the mobile viewport.
|
||||||
|
const introInner = intro.locator(".content-wrapper").first();
|
||||||
|
const introInnerBox = await getBox(introInner);
|
||||||
|
expect(introInnerBox.x + introInnerBox.width).toBeLessThanOrEqual(
|
||||||
|
402 + 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No JS errors.
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 320: nothing clips horizontally", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-intro").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
|
||||||
|
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(320 + 3);
|
||||||
|
expect(introBox.x + introBox.width).toBeLessThanOrEqual(320 + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 767: layout still holds before the desktop breakpoint", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 767, height: 900 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-intro").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
|
||||||
|
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(767 + 3);
|
||||||
|
expect(introBox.x + introBox.width).toBeLessThanOrEqual(767 + 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new test**
|
||||||
|
|
||||||
|
Run: `npx playwright test tests/inner-page.spec.js`
|
||||||
|
Expected: 4 tests pass. If a test fails, read the failure, fix the underlying issue (template/CSS, not the test), and re-run until all 4 pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `npx playwright test`
|
||||||
|
Expected: the existing 36 tests in `tests/mobile-homepage.spec.js` and `tests/site-a11y.spec.js` still pass, plus the 4 new ones (40 total).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/inner-page.spec.js
|
||||||
|
git commit -m "Add Playwright coverage for inner page layout at all viewports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Final quality gate
|
||||||
|
|
||||||
|
**Files:** none modified.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the production CSS build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: a successful build with a fresh `static/dist/theme.css`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run PHPCS over the whole theme**
|
||||||
|
|
||||||
|
Run: `composer lint`
|
||||||
|
Expected: no new errors. Existing errors (if any) are pre-existing and out of scope.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full Playwright suite (final pass)**
|
||||||
|
|
||||||
|
Run: `npx playwright test`
|
||||||
|
Expected: all tests pass (40+ tests, 0 failures).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm no stray files**
|
||||||
|
|
||||||
|
Run: `git status`
|
||||||
|
Expected: working tree clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit any leftover build artifacts**
|
||||||
|
|
||||||
|
If `static/dist/theme.css` was rebuilt and not yet committed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add static/dist/theme.css
|
||||||
|
git commit -m "Rebuild theme.css after final quality gate"
|
||||||
|
```
|
||||||
|
|
||||||
|
If nothing to commit, skip this step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
|
||||||
|
| Spec requirement | Task |
|
||||||
|
| --- | --- |
|
||||||
|
| Add `hero_image` and `hero_vector` fields to Page Heading ACF group | Task 1 |
|
||||||
|
| Render `hero_image` in `views/partials/page-hero.php` with mask treatment | Task 5 (markup) + Task 4 (CSS) |
|
||||||
|
| Render `hero_vector` in `views/partials/page-hero.php`, right-anchored | Task 5 + Task 4 |
|
||||||
|
| Stop rendering the in-hero `intro` paragraph | Task 5 |
|
||||||
|
| New `views/partials/page-intro.php` partial with centered narrow column | Task 2 |
|
||||||
|
| `page.php` renders intro partial between hero and body when `intro` is set | Task 3 |
|
||||||
|
| New `views/partials/page-hero.css` for media-layer styles | Task 4 |
|
||||||
|
| Playwright tests at desktop, 402px, 320px, 767px | Task 6 |
|
||||||
|
| No-intro and no-asset fallback tests | Task 6 (added by final review fix) |
|
||||||
|
| No new ACF blocks | Verified — none added |
|
||||||
|
| Front page, single, archive, search, 404 unchanged | Verified — no template changes outside `page.php` and the partial |
|
||||||
|
| Bottom dark block is the existing footer (not a new CTA) | Verified — no contact-block added |
|
||||||
|
| `npm run build`, full Playwright, `composer lint` | Task 7 |
|
||||||
|
|
||||||
|
**Production-template fixes applied by the Task 6 implementer (documented here so future readers don't reproduce the bugs):**
|
||||||
|
|
||||||
|
| Defect the brief's assertions revealed | Fix | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `expect(introBox.y).toBeLessThan(contentBox.y)` failed because the page-intro partial was rendered as a child of `<div class="entry-content">`, so the two bounding boxes shared the same top y. | `page.php` now loads the page-intro partial as a **sibling** of the `entry-content` div, not inside it. | Intro and body become peers in the layout flow, so the order check holds. |
|
||||||
|
| The intro's `<div class="container mx-auto max-w-3xl content-wrapper">` produced an ~1184px wide intro on desktop (the `max-w-3xl` cap was overridden by `.container`'s `max-width: 80.5rem` from `styles/base/global.css`, which loads after the Tailwind utilities in the cascade). | `views/partials/page-intro.php` now uses `<div class="mx-auto max-w-3xl content-wrapper">` (no `container` class). | `max-w-3xl` (768px) is the only constraint, so the intro is correctly capped on desktop. |
|
||||||
|
|
||||||
|
**Placeholder scan:** No TBD/TODO. Every step has exact code or commands. No "similar to Task N" references.
|
||||||
|
|
||||||
|
**Type / interface consistency:**
|
||||||
|
|
||||||
|
- `get_field( 'hero_image' )` defined in Task 1, consumed in Task 5 as `['url']` array. Same for `hero_vector`. ✓
|
||||||
|
- `get_template_part( 'views/partials/page-intro' )` used in Task 3, the file created in Task 2. ✓
|
||||||
|
- `.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content` classes defined in Task 4 CSS, used in Task 5 markup, asserted in Task 6. ✓
|
||||||
|
- `pageUrl` in Task 6 default points to the same `sample-page` fixture referenced in the spec. ✓
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Inner Page Layout
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build the inner page layout shown in `notes/inner-page-mockup.png` so that every WordPress page renders the same structure: a blue-to-teal gradient hero with the page title, an optional hero image at its natural size on the right with the bottom hanging over the hero's bottom edge, a decorative vector along the lower-left with breathing room from the bottom, an optional intro text block (the editor-supplied `intro` field) on the left below the H1, and the standard editor body below the hero. Different pages vary only by hero image, vector, intro text, and body content.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Applies to all `page` post-type views rendered by `page.php` (and through it, the `page-hero` partial that `header.php` already loads).
|
||||||
|
- Does not change the front page, single posts, archives, search, or 404 templates.
|
||||||
|
- Does not change the homepage-hero block.
|
||||||
|
- Does not add a new contact block to the page body — the dark navy block with the orange rule in the mockup is the existing site footer, not a new CTA.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The layout is a fixed two-part composition:
|
||||||
|
|
||||||
|
1. **Hero** — `views/partials/page-hero.php` (existing), extended with the blue-to-teal gradient backdrop, two new ACF fields, the hero image rendered on the right (faded to transparent on the left so the heading has a clear backdrop), a decorative vector along the bottom, the heading, and the intro block.
|
||||||
|
2. **Body** — `the_content`, unchanged.
|
||||||
|
|
||||||
|
`page-hero.php` is the orchestrator: it renders the gradient backdrop, the optional hero image and vector, the heading, and the intro block all inside a single `.page-hero` wrapper. The `intro` ACF field, when set, renders as a two-column heading + body block inside the hero (not as a separate page section). The intro heading is a hard-coded "Our Work" label; the body is the editor-supplied `intro` text.
|
||||||
|
|
||||||
|
`page.php` continues to render `the_content` after the hero. Sidebar behavior (already gated by `hasSidebar()` and the `Page Sidebar` field group) is preserved. `page.php` no longer loads the `page-intro` partial directly — the partial is loaded by `page-hero.php` instead.
|
||||||
|
|
||||||
|
`hasPageHeader()` in `lib/extras.php` continues to gate the hero. The function's current behavior (hero shows when `hero_style !== 'none'`) is kept; no change to that filter is required.
|
||||||
|
|
||||||
|
## ACF Field Group Changes
|
||||||
|
|
||||||
|
Add two fields to the existing `Page Heading` group (`acf/group_60bfb84ae973c.json`):
|
||||||
|
|
||||||
|
- `hero_image` — type: `image`, return_format: `array`, library: `all`, preview_size: `medium`. Conditional: shown when `hero_style !== 'none'`. Optional.
|
||||||
|
- `hero_vector` — type: `image`, return_format: `array`, library: `all`, preview_size: `medium`. Conditional: shown when `hero_style !== 'none'`. Optional.
|
||||||
|
|
||||||
|
No other ACF group changes. The existing `heading`, `intro`, and `call_to_actions` fields are reused as-is.
|
||||||
|
|
||||||
|
## Hero Partial — `views/partials/page-hero.php`
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- The wrapper `<div>` no longer carries the `bg-dark` or `bg-cover bg-no-repeat` Tailwind utilities. The wrapper inherits a blue-to-teal gradient from `.page-hero` (defined in `styles/blocks/page-hero.css`), the same gradient used by the homepage-hero block. When the editor sets a `background_color` ACF field, the inline `style` attribute on the wrapper overrides the gradient with that solid color.
|
||||||
|
- Read the two new fields (`hero_image`, `hero_vector`) and the existing `intro` field.
|
||||||
|
- Render the breadcrumbs, the H1, and the intro **inside** a single `.page-hero__content` container, **before** the image and vector markup. The H1 has a dedicated `.page-hero__heading` class for typography control. The intro, when non-empty, is a single `.page-hero__intro` div containing only the editor-supplied intro text (no hard-coded heading).
|
||||||
|
- Render `hero_image` as a foreground absolutely-positioned `<img>` (class `.page-hero__media`), anchored to the right of the hero with `bottom: -10%` so the image's bottom hangs over the bottom edge of the hero. The image is shown at its natural size (no `object-fit: cover`, no mask); it sits above the gradient backdrop and below the content layer.
|
||||||
|
- Render `hero_vector` as an absolutely-positioned `<img>` (class `.page-hero__vector`), anchored to the bottom-left of the hero with `bottom: clamp(1.5rem, 4vw, 3rem)` for spacing from the bottom edge. The vector sits above the image and below the content.
|
||||||
|
- When no `hero_image` is set, the gradient (or the override `background_color`) is the only background. When no `hero_vector` is set, the bottom of the hero is just the gradient.
|
||||||
|
|
||||||
|
Both new images are `loading="lazy"` and decorative. No hard-coded copy is rendered.
|
||||||
|
|
||||||
|
## Hero Partial CSS — `styles/blocks/page-hero.css` (new)
|
||||||
|
|
||||||
|
A dedicated file follows the project's existing pattern (`contact-block.css`, `homepage-hero.css`).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `.page-hero` — the wrapper. `position: relative; isolation: isolate; overflow: hidden; min-height: clamp(31rem, 38vw, 36rem);`. `background: linear-gradient(83.68deg, #032F46 3.13%, #006196 45.91%, #8FC9E6 96.27%);` — same gradient as the homepage-hero block.
|
||||||
|
- `.page-hero__content` — `position: relative; z-index: 10; padding-block: clamp(2rem, 6vw, 4rem);` so the heading, intro, and breadcrumbs always sit above the image and vector layers.
|
||||||
|
- `.page-hero__heading` — `font-family: var(--font-quincy, 'Quincy', serif); font-weight: 400; font-size: clamp(2.5rem, 6vw, 4.5rem); line-height: 1.05; max-width: 36rem;` so the H1 doesn't span the full hero width and stays aligned with the intro text.
|
||||||
|
- `.page-hero__intro` — `font-size: clamp(1rem, 1.5vw, 1.25rem); line-height: 1.5; max-width: 36rem; opacity: 0.95;` so the intro text sits alongside the H1.
|
||||||
|
- `.page-hero__media` — `position: absolute; right: clamp(1.5rem, 4vw, 4rem); bottom: -10%; width: clamp(18rem, 32vw, 28rem); height: auto; pointer-events: none; z-index: 1;`. The image is shown at its natural size.
|
||||||
|
- `.page-hero__vector` — `position: absolute; left: 0; bottom: clamp(1.5rem, 4vw, 3rem); width: clamp(28rem, 60vw, 56rem); height: auto; pointer-events: none; z-index: 5;`. The vector is sized to span the lower-left of the hero with breathing room from the bottom edge.
|
||||||
|
- Mobile (≤ 767px) breakpoint: hero min-height becomes `clamp(28rem, 130vw, 40rem)`; image shrinks to `60vw` and sits flush right (`right: 0`); vector shrinks to `80vw`.
|
||||||
|
|
||||||
|
These rules are scoped to `.page-hero` and only affect the page-hero partial. They do not affect the homepage-hero block, which uses a different root class.
|
||||||
|
|
||||||
|
## page.php Wiring
|
||||||
|
|
||||||
|
`page.php` continues to render the article container, `the_content`, and the sidebar (when `hasSidebar()`). The hero is rendered by `header.php` before this template runs. No other behavior changes.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
- Editor experience: on every page (and post) edit screen, the existing Page Heading meta box shows the existing fields plus two new ones (Hero Image, Vector). The editor uploads the assets, sets a background color if desired, writes the optional heading override, intro, and CTAs, then writes the body in the standard block editor.
|
||||||
|
- Render: `header.php` loads the page-hero partial → `page-hero.php` renders the gradient + optional hero image + vector + heading + (when `intro` is set) the intro block, all inside `.page-hero` → `page.php` renders `the_content` for the body blocks.
|
||||||
|
- Per-page variation: hero image, vector, intro text, and body content. Everything else is shared.
|
||||||
|
|
||||||
|
## Error Handling and Edge Cases
|
||||||
|
|
||||||
|
- `hero_image` empty → hero uses the gradient (or the override `background_color`) as the only background.
|
||||||
|
- `hero_vector` empty → no vector renders; the bottom of the hero is just the gradient.
|
||||||
|
- `intro` empty → the `.page-hero__intro` div is not rendered; the hero shows the H1 only.
|
||||||
|
- `heading` empty → falls back to `getTheTitle()` (existing behavior, unchanged).
|
||||||
|
- Background color set without a hero image → existing behavior; the inline `style` overrides the gradient with the solid color.
|
||||||
|
- No sidebar field set → existing `hasSidebar()` filter returns false → no sidebar (existing behavior).
|
||||||
|
- Lazy-loaded images must await `load`/`complete` before Playwright reads dimensions (existing project convention; see `tests/mobile-homepage.spec.js:641`).
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- New ACF blocks. This layout uses the existing Page Heading meta box, not the block editor.
|
||||||
|
- Front page, single posts, archives, search, 404 changes.
|
||||||
|
- Homepage-hero block changes.
|
||||||
|
- The contact-block, pull-quote, our-work, or other existing blocks.
|
||||||
|
- A new contact CTA block in the page body. The mockup's bottom dark block is the existing site footer.
|
||||||
|
- Migration of existing pages' content.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Add `tests/inner-page.spec.js` covering:
|
||||||
|
- Desktop (1280px): hero, intro, body render with the intro block as a descendant of the hero. The H1 is visible. The hero exposes the gradient backdrop.
|
||||||
|
- Mobile (402px): intro is inside the hero and fits within the viewport. The vector is visible at the bottom of the hero. No clipped text.
|
||||||
|
- 320px and 767px: nothing clips, hero and intro remain inside the viewport.
|
||||||
|
- A page with no `intro` field renders the hero without a `.page-hero__intro` wrapper.
|
||||||
|
- A page with no `hero_image` and no `hero_vector` renders the existing gradient text-only hero unchanged.
|
||||||
|
- Add a test page fixture (or use an existing page) to drive the test. If no fixture exists, the test seeds a page in a `beforeAll` and tears it down in `afterAll`.
|
||||||
|
- Run `npm run build` to regenerate `static/dist/`.
|
||||||
|
- Run the full Playwright suite (`npx playwright test`) — including the existing `tests/mobile-homepage.spec.js` and `tests/site-a11y.spec.js` — to confirm no regressions.
|
||||||
|
- Run `composer lint` to catch PHPCS issues.
|
||||||
|
- Capture a 1280px and a 402px full-page screenshot of the test fixture for visual comparison against `notes/inner-page-mockup.png`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. Every WordPress page renders the new layout when the page-hero partial is active.
|
||||||
|
2. The hero shows the blue-to-teal gradient backdrop, the page title (or heading override) in the Quincy serif face, and — when supplied — a hero image at its natural size on the right with the bottom hanging over the hero's bottom edge, and a decorative vector along the lower-left with some spacing from the bottom edge.
|
||||||
|
3. When the `intro` field is set, a single text block appears inside the hero below the H1, in the same left column. When empty, the intro block is omitted entirely.
|
||||||
|
4. The body renders `the_content` as before, with the standard block editor.
|
||||||
|
5. No hard-coded copy is rendered by the partial — only the page title, the editor-supplied `intro` text, and the editor-supplied images.
|
||||||
|
6. The hero image and vector render at all viewports without clipping.
|
||||||
|
7. The page renders without console errors, axe violations, or PHPCS errors.
|
||||||
|
8. Existing single posts, archives, search, and 404 pages render the page-hero partial unchanged.
|
||||||
|
9. Existing tests (`mobile-homepage`, `site-a11y`) continue to pass.
|
||||||
+40
-35
@@ -143,14 +143,17 @@ class Breadcrumbs {
|
|||||||
private static function getCustomPostTypeBreadcrumbs() {
|
private static function getCustomPostTypeBreadcrumbs() {
|
||||||
$breadcrumbs = array();
|
$breadcrumbs = array();
|
||||||
$postType = get_post_type_object( get_post_type() );
|
$postType = get_post_type_object( get_post_type() );
|
||||||
if ( $postType && ! in_array( get_post_type(), array( 'post', 'page' ), true ) ) {
|
|
||||||
|
if ( $postType && ! in_array( get_post_type(), array( 'post', 'page' ), true ) ) {
|
||||||
$breadcrumbs[] = array(
|
$breadcrumbs[] = array(
|
||||||
'url' => get_post_type_archive_link( $postType->name ),
|
'url' => get_post_type_archive_link( $postType->name ),
|
||||||
'label' => $postType->labels->name,
|
'label' => $postType->labels->name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$breadcrumbs[] = array( 'label' => get_the_title() );
|
|
||||||
return $breadcrumbs;
|
$breadcrumbs[] = array( 'label' => get_the_title() );
|
||||||
|
|
||||||
|
return $breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates breadcrumbs for a static page.
|
/** Generates breadcrumbs for a static page.
|
||||||
@@ -165,17 +168,21 @@ class Breadcrumbs {
|
|||||||
*/
|
*/
|
||||||
private static function getStaticPageBreadcrumbs( $post ) {
|
private static function getStaticPageBreadcrumbs( $post ) {
|
||||||
$breadcrumbs = array();
|
$breadcrumbs = array();
|
||||||
if ( $post->post_parent ) {
|
|
||||||
|
if ( $post->post_parent ) {
|
||||||
$ancestors = array_reverse( get_post_ancestors( $post->ID ) );
|
$ancestors = array_reverse( get_post_ancestors( $post->ID ) );
|
||||||
foreach ( $ancestors as $ancestor ) {
|
|
||||||
|
foreach ( $ancestors as $ancestor ) {
|
||||||
$breadcrumbs[] = array(
|
$breadcrumbs[] = array(
|
||||||
'url' => get_permalink( $ancestor ),
|
'url' => get_permalink( $ancestor ),
|
||||||
'label' => get_the_title( $ancestor ),
|
'label' => get_the_title( $ancestor ),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$breadcrumbs[] = array( 'label' => get_the_title() );
|
|
||||||
return $breadcrumbs;
|
$breadcrumbs[] = array( 'label' => get_the_title() );
|
||||||
|
|
||||||
|
return $breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates a breadcrumb for taxonomy archive pages.
|
/** Generates a breadcrumb for taxonomy archive pages.
|
||||||
@@ -257,7 +264,8 @@ class Breadcrumbs {
|
|||||||
*/
|
*/
|
||||||
private static function getAuthorArchiveBreadcrumb() {
|
private static function getAuthorArchiveBreadcrumb() {
|
||||||
$author = get_queried_object();
|
$author = get_queried_object();
|
||||||
return array( 'label' => 'Author: ' . $author->display_name );
|
|
||||||
|
return array( 'label' => 'Author: ' . $author->display_name );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates the breadcrumb for search results.
|
/** Generates the breadcrumb for search results.
|
||||||
@@ -314,35 +322,32 @@ class Breadcrumbs {
|
|||||||
$isActive = ( $index === count( $items ) - 1 );
|
$isActive = ( $index === count( $items ) - 1 );
|
||||||
$activeClass = $isActive ? ' active' : '';
|
$activeClass = $isActive ? ' active' : '';
|
||||||
?>
|
?>
|
||||||
<span class="p-2 breadcrumb-item<?php echo esc_attr( $activeClass ); ?>"
|
<span class="p-2 breadcrumb-item<?php echo esc_attr( $activeClass ); ?>"
|
||||||
<?php
|
<?php if ( $isActive ) : ?>
|
||||||
if ( $isActive ) :
|
aria-current="page"
|
||||||
?>
|
<?php endif; ?>>
|
||||||
aria-current="page"<?php endif; ?>>
|
|
||||||
<?php if ( ! empty( $item['url'] ) ) : ?>
|
|
||||||
<a href="<?php echo esc_url( $item['url'] ); ?>">
|
|
||||||
<?php if ( $index === 0 ) : ?>
|
|
||||||
<svg class="flex-shrink-0 w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" aria-label="Home">
|
|
||||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only"><?php echo esc_attr( $item['label'] ); ?></span>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php echo esc_attr( $item['label'] ); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</a>
|
|
||||||
<?php $ldItem['item'] = $item['url']; ?>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php echo esc_attr( $item['label'] ); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<?php $metadata['itemListElement'][] = $ldItem; ?>
|
<?php if ( ! empty( $item['url'] ) ) : ?>
|
||||||
|
<a href="<?php echo esc_url( $item['url'] ); ?>">
|
||||||
|
<?php if ( $index === 0 ) : ?>
|
||||||
|
<?php echo esc_attr( $item['label'] ); ?>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php echo esc_attr( $item['label'] ); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php $ldItem['item'] = $item['url']; ?>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php echo esc_attr( $item['label'] ); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
<?php if ( ! $isActive ) : ?>
|
<?php $metadata['itemListElement'][] = $ldItem; ?>
|
||||||
<svg class="flex-shrink-0 w-5 h-5 text-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
||||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
<?php if ( ! $isActive ) : ?>
|
||||||
</svg>
|
<svg class="flex-shrink-0 w-5 h-5 text-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<?php endif; ?>
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ if ( hasSidebar() ) {
|
|||||||
<article class="<?php echo esc_attr( $classes ); ?>">
|
<article class="<?php echo esc_attr( $classes ); ?>">
|
||||||
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
|
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
|
||||||
<?php
|
<?php
|
||||||
|
// Page body content
|
||||||
if ( have_posts() ) {
|
if ( have_posts() ) {
|
||||||
while ( have_posts() ) {
|
while ( have_posts() ) {
|
||||||
the_post();
|
the_post();
|
||||||
|
|||||||
Vendored
+5286
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,4 @@
|
|||||||
@import './buttons.css';
|
@import './buttons.css';
|
||||||
@import './core.css';
|
@import './core.css';
|
||||||
@import './misc.css';
|
@import './misc.css';
|
||||||
|
@import './page-hero.css';
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/* Page Hero partial styles (page-hero.php)
|
||||||
|
* Scoped to .page-hero to avoid affecting the homepage-hero block. */
|
||||||
|
|
||||||
|
.page-hero {
|
||||||
|
background: linear-gradient(73.83deg, #8FC9E6 -8.7%, #006196 21.51%, #032F46 60.7%);
|
||||||
|
isolation: isolate;
|
||||||
|
margin-bottom: 10rem;
|
||||||
|
min-height: clamp(31rem, 38vw, 36rem);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__content {
|
||||||
|
padding-block: clamp(2rem, 6vw, 4rem);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__heading {
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 3rem 0 1.5rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
em {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__intro {
|
||||||
|
color: #fff;
|
||||||
|
font-size: clamp(1rem, 1.5vw, 1.25rem);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 36rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__media {
|
||||||
|
bottom: -5%;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 23vw;
|
||||||
|
top: 3rem;
|
||||||
|
width: auto;
|
||||||
|
z-index: 1;
|
||||||
|
/* Set width via max-width so the image's natural aspect ratio is preserved. */
|
||||||
|
max-width: clamp(20rem, 30vw, 28rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__vector {
|
||||||
|
bottom: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
height: auto;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page-hero {
|
||||||
|
min-height: clamp(28rem, 130vw, 40rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__media {
|
||||||
|
bottom: -8%;
|
||||||
|
right: 0;
|
||||||
|
top: auto;
|
||||||
|
max-width: 60vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__vector {
|
||||||
|
bottom: clamp(1rem, 3vw, 2rem);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
/* Hover state: triggered by li so it persists when moving into the submenu.
|
/* Hover state: triggered by li so it persists when moving into the submenu.
|
||||||
Also triggered when a submenu is open (e.g. via keyboard/click). */
|
Also triggered when a submenu is open (e.g. via keyboard/click). */
|
||||||
&:hover,
|
&:hover,
|
||||||
|
&:has(a[aria-current="true"]),
|
||||||
&:has(.menu-vdi__toggle[aria-expanded="true"]) {
|
&:has(.menu-vdi__toggle[aria-expanded="true"]) {
|
||||||
>a,
|
>a,
|
||||||
>.menu-vdi__toggle {
|
>.menu-vdi__toggle {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Default rich fixture: all fields populated (hero_image, hero_vector, intro).
|
||||||
|
const pageUrl =
|
||||||
|
process.env.TEST_PAGE_URL ||
|
||||||
|
"http://community-works-collaborative.test/sample-page/";
|
||||||
|
|
||||||
|
// No-intro fixture: `intro` ACF field intentionally empty.
|
||||||
|
const noIntroUrl =
|
||||||
|
process.env.TEST_PAGE_URL_NO_INTRO ||
|
||||||
|
"http://community-works-collaborative.test/inner-page-no-intro/";
|
||||||
|
|
||||||
|
// No-asset fixture: `hero_image` and `hero_vector` ACF fields intentionally empty.
|
||||||
|
const noAssetUrl =
|
||||||
|
process.env.TEST_PAGE_URL_NO_ASSET ||
|
||||||
|
"http://community-works-collaborative.test/inner-page-no-asset/";
|
||||||
|
|
||||||
|
const getBox = async (locator) => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
return box;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectNear = (actual, expected, tolerance = 3) => {
|
||||||
|
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("inner page layout", () => {
|
||||||
|
test("desktop: hero, intro, body render with intro inside hero", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-hero__intro").first();
|
||||||
|
const content = page.locator(".entry-content").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
|
||||||
|
// Intro lives INSIDE the hero.
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
const contentBox = await getBox(content);
|
||||||
|
|
||||||
|
// Intro is a descendant of the hero (same x, y is between hero top and bottom).
|
||||||
|
expect(introBox.x).toBeGreaterThanOrEqual(heroBox.x - 1);
|
||||||
|
expect(introBox.y).toBeGreaterThanOrEqual(heroBox.y - 1);
|
||||||
|
expect(introBox.y + introBox.height).toBeLessThanOrEqual(
|
||||||
|
heroBox.y + heroBox.height + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Body renders below the hero.
|
||||||
|
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
|
||||||
|
|
||||||
|
// Heading is rendered inside the hero.
|
||||||
|
await expect(hero.locator("h1").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Page renders without JS errors.
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 402: hero, intro, body order and intro width", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 402, height: 874 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-hero__intro").first();
|
||||||
|
const content = page.locator(".entry-content").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
const contentBox = await getBox(content);
|
||||||
|
|
||||||
|
// Intro inside the hero.
|
||||||
|
expect(introBox.x).toBeGreaterThanOrEqual(heroBox.x - 1);
|
||||||
|
expect(introBox.y).toBeGreaterThanOrEqual(heroBox.y - 1);
|
||||||
|
expect(introBox.y + introBox.height).toBeLessThanOrEqual(
|
||||||
|
heroBox.y + heroBox.height + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Body below the hero.
|
||||||
|
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
|
||||||
|
|
||||||
|
// Intro fits within the mobile viewport.
|
||||||
|
expect(introBox.x + introBox.width).toBeLessThanOrEqual(402 + 3);
|
||||||
|
|
||||||
|
// No JS errors.
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 320: nothing clips horizontally", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-hero__intro").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
|
||||||
|
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(320 + 3);
|
||||||
|
expect(introBox.x + introBox.width).toBeLessThanOrEqual(320 + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile 767: layout still holds before the desktop breakpoint", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 767, height: 900 });
|
||||||
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const intro = page.locator(".page-hero__intro").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(intro).toBeVisible();
|
||||||
|
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const introBox = await getBox(intro);
|
||||||
|
|
||||||
|
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(767 + 3);
|
||||||
|
expect(introBox.x + introBox.width).toBeLessThanOrEqual(767 + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spec line 113: "A page with no `intro` field renders hero + body with no empty intro wrapper."
|
||||||
|
test("no-intro: hero and body render with no .page-hero__intro wrapper", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(noIntroUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
const content = page.locator(".entry-content").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
|
||||||
|
// No intro wrapper rendered when the intro field is empty.
|
||||||
|
expect(await page.locator(".page-hero__intro").count()).toBe(0);
|
||||||
|
|
||||||
|
// Hero and body still render in the expected order.
|
||||||
|
const heroBox = await getBox(hero);
|
||||||
|
const contentBox = await getBox(content);
|
||||||
|
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
|
||||||
|
|
||||||
|
// Heading is still inside the hero.
|
||||||
|
await expect(hero.locator("h1").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spec line 114: "A page with no `hero_image` and no `hero_vector` renders the existing dark text-only hero unchanged."
|
||||||
|
test("no-asset: dark text-only hero renders without image or vector", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(noAssetUrl);
|
||||||
|
|
||||||
|
const hero = page.locator(".page-hero").first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
|
||||||
|
// No media layers when both assets are empty.
|
||||||
|
expect(await page.locator(".page-hero__media").count()).toBe(0);
|
||||||
|
expect(await page.locator(".page-hero__vector").count()).toBe(0);
|
||||||
|
|
||||||
|
// Heading still renders inside the hero.
|
||||||
|
await expect(hero.locator("h1").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Hero still exposes the dark text-only class hook.
|
||||||
|
await expect(hero).toHaveClass(/page-hero/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,49 +8,64 @@
|
|||||||
namespace CWC;
|
namespace CWC;
|
||||||
|
|
||||||
// Set variables
|
// Set variables
|
||||||
$bgColor = get_field( 'background_color' );
|
$bgColor = get_field( 'background_color' );
|
||||||
$isDark = get_field( 'is_dark' );
|
$isDark = get_field( 'is_dark' );
|
||||||
$heading = get_field( 'heading' );
|
$heading = get_field( 'heading' );
|
||||||
$intro = get_field( 'intro' );
|
$heroImage = get_field( 'hero_image' );
|
||||||
|
$heroVector = get_field( 'hero_vector' );
|
||||||
|
$intro = get_field( 'intro' );
|
||||||
|
|
||||||
// Fallback for heading
|
// Fallback for heading
|
||||||
if ( ! $heading ) {
|
if ( ! $heading ) {
|
||||||
$heading = getTheTitle();
|
$heading = getTheTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional logic for dark mode (if needed)
|
// Additional logic for dark mode (if needed)
|
||||||
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
|
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
|
||||||
$isDark = true;
|
$isDark = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The wrapper color is the gradient from .page-hero in page-hero.css. The
|
||||||
|
// editor's background_color ACF field overrides the gradient with a solid color.
|
||||||
|
$wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="bg-cover bg-no-repeat mb-12 py-12 lg:py-16 bg-dark text-light overflow-hidden <?php echo $isDark ? 'dark' : ''; ?>" <?php echo $bgColor ? 'style="background-color: ' . esc_attr( $bgColor ) . '"' : ''; ?>>
|
<div class="page-hero text-light <?php echo $isDark ? 'dark' : ''; ?>" style="<?php echo esc_attr( $wrapperStyle ); ?>">
|
||||||
|
<div class="page-hero__content container mx-auto px-0!">
|
||||||
|
<!-- <div id="breadcrumbs">
|
||||||
|
<?php Breadcrumbs::render(); ?>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
<div class="container mx-auto">
|
<h1 class="page-hero__heading">
|
||||||
<div id="breadcrumbs">
|
<?php echo wp_kses_post( $heading ); ?><em>.</em>
|
||||||
<?php Breadcrumbs::render(); ?>
|
</h1>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:text-center lg:items-start lg:text-left content-wrapper">
|
<?php if ( $intro ) : ?>
|
||||||
<?php
|
<div class="page-hero__intro">
|
||||||
// Heading
|
<?php echo wp_kses_post( $intro ); ?>
|
||||||
if ( apply_filters( 'include_page_title_in_hero', true ) ) {
|
</div>
|
||||||
echo '<h1 class="mx-auto text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
|
<?php endif; ?>
|
||||||
echo wp_kses_post( $heading );
|
</div>
|
||||||
echo '</h1>';
|
|
||||||
} else {
|
|
||||||
echo '<span class="mx-auto block text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
|
|
||||||
echo wp_kses_post( $heading );
|
|
||||||
echo '</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intro
|
<?php if ( $heroImage ) : ?>
|
||||||
if ( $intro ) {
|
<img
|
||||||
echo '<p class="mt-3 text-base text-light sm:mt-5 sm:text-xl lg:text-lg xl:text-xl text-center">';
|
class="page-hero__media"
|
||||||
echo wp_kses_post( $intro );
|
src="<?php echo esc_url( $heroImage['url'] ); ?>"
|
||||||
echo '</p>';
|
alt=""
|
||||||
}
|
loading="lazy"
|
||||||
?>
|
aria-hidden="true"
|
||||||
</div>
|
role="presentation"
|
||||||
</div>
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( $heroVector ) : ?>
|
||||||
|
<img
|
||||||
|
class="page-hero__vector"
|
||||||
|
src="<?php echo esc_url( $heroVector['url'] ); ?>"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user