Compare commits

...
19 Commits
Author SHA1 Message Date
Keith Solomon dddb4b8c54 feature: Build interior layout
Deploy to Dreamhost (dev) / build (push) Successful in 38s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-01 18:33:01 -05:00
Keith SolomonandClaude 6452f28932 Refine hero: vector full-width, image top padding, content flush left
- styles/blocks/page-hero.css:
  - .page-hero__vector: width is now 100% (was clamp(28rem, 60vw, 56rem))
    so the orange linework spans the full width of the hero. The SVG
    scales proportionally (1920x489 viewBox).
  - .page-hero__media: added top: clamp(2rem, 4vw, 4rem) so the image
    has visible top padding inside the hero (in addition to overflowing
    the bottom). Reduced right offset to clamp(0.5rem, 2vw, 2rem) so
    the image sits closer to the right edge. Width is 'auto' with
    max-width: clamp(20rem, 30vw, 28rem) so the image's natural aspect
    ratio (490x613 portrait) is preserved.
- views/partials/page-hero.php: .page-hero__content is now 'container
  mx-auto px-0!' so the breadcrumbs, H1, and intro sit flush left at
  x=0, matching the logo's left edge (which is also at x=0 thanks to
  the header's px-0! override on its container).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 16:39:29 -05:00
Keith Solomon 4aa6ff694d Update inner page layout spec for hero restructure (no hard-coded copy, image at natural size) 2026-07-01 16:12:48 -05:00
Keith SolomonandClaude 2bda7f21eb Restructure hero: image at natural size overflowing, intro as text, no hard-coded copy
- views/partials/page-hero.php: removed the page-intro partial call. The
  intro is now rendered inline as a single .page-hero__intro div
  containing the editor-supplied intro text only (no hard-coded
  heading). The H1 is rendered with a dedicated .page-hero__heading
  class for typography control. Breadcrumbs + H1 + intro are in a
  single .page-hero__content container.
- views/partials/page-intro.php: deleted (no longer used).
- styles/blocks/page-hero.css: rewritten layout. The hero has a min-height
  of clamp(31rem, 38vw, 36rem). .page-hero__media is a foreground
  absolutely-positioned img on the right (no mask, no cover, no
  object-fit), with bottom: -10% so the image's bottom hangs over
  the bottom edge of the hero. The vector is positioned at
  bottom: clamp(1.5rem, 4vw, 3rem) with some spacing from the bottom
  edge, full-width on the left. Both elements stay above the content
  layer (z-index 10) and below the breadcrumb/title text. The H1 uses
  Quincy serif at clamp(2.5rem, 6vw, 4.5rem). The intro text is
  capped at 36rem to sit alongside the H1 without spanning the full
  hero width.
- tests/inner-page.spec.js: removed the now-obsolete
  .page-hero__intro-heading / .page-hero__intro-text assertions (the
  intro is a single text block now).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 16:11:28 -05:00
Keith SolomonandClaude ec87cae6c5 Update inner page layout spec for hero+intro refactor
- Goal: hero is now a blue-to-teal gradient (not a flat dark color) and the
  intro block lives inside the hero composition (not as a separate page
  section).
- Architecture: reduced from 3-part (hero / intro / body) to 2-part
  (hero / body) since the intro is now embedded in the hero.
- Hero Partial: dropped the bg-dark utility (gradient is set in CSS), the
  hero image is anchored to the right with a left-to-right fade, and the
  vector is full-width anchored to the bottom at z-index 2.
- Hero Partial CSS: the file is at styles/blocks/page-hero.css (not
  views/partials/page-hero.css). Updated the rules to match the new
  gradient + image right-side + vector bottom-center + intro grid layout.
- Intro Partial: rewritten as a fragment for embedding inside the hero
  (two-column heading + body grid) rather than a centered standalone
  section. Partial is loaded by page-hero.php, not page.php.
- page.php Wiring: no longer loads the page-intro partial directly; the
  partial is loaded by page-hero.php.
- Error Handling, Verification, and Acceptance Criteria: updated to
  match the new structure (no empty .page-intro wrapper check; intro is
  a descendant of the hero; gradient + image-right + vector-bottom-center
  expectations).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 15:26:12 -05:00
Keith SolomonandClaude e221f62ef7 Refine hero: gradient backdrop, intro inside hero, image on right, vector visible
- styles/blocks/page-hero.css: add blue-to-teal gradient to .page-hero (matches
  homepage-hero). Fix image mask direction (right-to-left fade) so the image
  is visible on the right and fades to transparent on the left. Reposition
  the vector to span the full bottom of the hero at z-index 2 (above the
  media layer, below content). Add .page-hero__intro and .page-hero__intro-inner
  grid rules for the new two-column heading + body block.
- views/partials/page-hero.php: drop bg-dark / bg-cover / bg-no-repeat
  utilities (the gradient comes from CSS now). Simplify the heading render.
  Load the page-intro partial inside .page-hero__content, below the heading.
- views/partials/page-intro.php: rewrite as a fragment for embedding inside
  the hero - a two-column heading (hard-coded 'Our Work') + body grid.
  Returns early when the intro field is empty.
- page.php: remove the page-intro partial call (it's now loaded from
  page-hero.php).
- tests/inner-page.spec.js: switch the selector from .page-intro to
  .page-hero__intro. Update the order assertions to verify the intro is
  a descendant of the hero (not a sibling below it).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 15:25:39 -05:00
Keith Solomon 342ae015c7 Document the two production-template fixes in the inner-page plan 2026-07-01 12:17:14 -05:00
Keith Solomon 667cb2ec89 Document the two production-template fixes in the inner-page spec 2026-07-01 12:16:52 -05:00
Keith Solomon 8b7819e932 Add no-intro and no-asset fallback tests for inner page layout 2026-07-01 12:16:38 -05:00
Keith Solomon 998619fa3f Add Playwright coverage for inner page layout at all viewports 2026-07-01 11:13:34 -05:00
Keith Solomon cda3878169 Render hero_image and hero_vector in page-hero partial; drop in-hero intro 2026-07-01 10:29:26 -05:00
Keith Solomon edf3d6c5ce Add page-hero media layer CSS and import into blocks bundle 2026-07-01 10:22:29 -05:00
Keith Solomon d389fd7bc8 Fix Task 3 path (page.php is at theme root) and indentation (use tabs) 2026-07-01 10:19:21 -05:00
Keith Solomon a8affd474d Render page-intro partial between hero and body in page.php 2026-07-01 10:16:15 -05:00
Keith Solomon ffdf79ae01 Add page-intro partial for centered narrow intro section 2026-07-01 10:10:00 -05:00
Keith Solomon 145df12149 Add hero_image and hero_vector fields to Page Heading group 2026-07-01 10:03:23 -05:00
Keith SolomonandClaude 2e0b4225a1 Add inner page layout implementation plan
Seven tasks: ACF fields, page-intro partial, page.php wiring, scoped CSS,
page-hero partial update, Playwright tests, and a final quality gate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 09:55:38 -05:00
Keith Solomon 028fcf28c1 Clarify hero background fallback in inner page layout spec 2026-07-01 08:37:28 -05:00
Keith SolomonandClaude 455a005e58 Add inner page layout design spec
Defines the inner page layout: dark hero with background image and decorative
vector (driven by two new fields on the existing Page Heading ACF group), a
centered narrow intro section underneath (driven by the existing Intro field),
and the standard the_content body.

The mockup's bottom dark block is the existing site footer, not a new CTA. No
new ACF blocks are introduced; the layout reuses the existing Page Heading meta
box and the page-hero partial that header.php already loads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 08:36:37 -05:00
11 changed files with 6589 additions and 68 deletions
+69 -1
View File
@@ -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.
+13 -8
View File
@@ -143,13 +143,16 @@ 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() ); $breadcrumbs[] = array( 'label' => get_the_title() );
return $breadcrumbs; return $breadcrumbs;
} }
@@ -165,8 +168,10 @@ 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 ),
@@ -174,7 +179,9 @@ class Breadcrumbs {
); );
} }
} }
$breadcrumbs[] = array( 'label' => get_the_title() ); $breadcrumbs[] = array( 'label' => get_the_title() );
return $breadcrumbs; return $breadcrumbs;
} }
@@ -257,6 +264,7 @@ 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 );
} }
@@ -315,17 +323,14 @@ class Breadcrumbs {
$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'] ) ) : ?> <?php if ( ! empty( $item['url'] ) ) : ?>
<a href="<?php echo esc_url( $item['url'] ); ?>"> <a href="<?php echo esc_url( $item['url'] ); ?>">
<?php if ( $index === 0 ) : ?> <?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"> <?php echo esc_attr( $item['label'] ); ?>
<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 else : ?>
<?php echo esc_attr( $item['label'] ); ?> <?php echo esc_attr( $item['label'] ); ?>
<?php endif; ?> <?php endif; ?>
+1
View File
@@ -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();
+5286
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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';
+81
View File
@@ -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%;
}
}
+1
View File
@@ -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 {
+188
View File
@@ -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/);
});
});
+40 -25
View File
@@ -11,6 +11,8 @@ namespace CWC;
$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' );
$heroImage = get_field( 'hero_image' );
$heroVector = get_field( 'hero_vector' );
$intro = get_field( 'intro' ); $intro = get_field( 'intro' );
// Fallback for heading // Fallback for heading
@@ -22,35 +24,48 @@ if ( ! $heading ) {
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 class="container mx-auto"> <!-- <div id="breadcrumbs">
<div id="breadcrumbs">
<?php Breadcrumbs::render(); ?> <?php Breadcrumbs::render(); ?>
</div> -->
<h1 class="page-hero__heading">
<?php echo wp_kses_post( $heading ); ?><em>.</em>
</h1>
<?php if ( $intro ) : ?>
<div class="page-hero__intro">
<?php echo wp_kses_post( $intro ); ?>
</div>
<?php endif; ?>
</div> </div>
<div class="sm:text-center lg:items-start lg:text-left content-wrapper"> <?php if ( $heroImage ) : ?>
<?php <img
// Heading class="page-hero__media"
if ( apply_filters( 'include_page_title_in_hero', true ) ) { src="<?php echo esc_url( $heroImage['url'] ); ?>"
echo '<h1 class="mx-auto text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">'; alt=""
echo wp_kses_post( $heading ); loading="lazy"
echo '</h1>'; aria-hidden="true"
} else { role="presentation"
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 ); <?php endif; ?>
echo '</span>';
}
// Intro <?php if ( $heroVector ) : ?>
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__vector"
echo wp_kses_post( $intro ); src="<?php echo esc_url( $heroVector['url'] ); ?>"
echo '</p>'; alt=""
} loading="lazy"
?> aria-hidden="true"
</div> role="presentation"
</div> />
<?php endif; ?>
</div> </div>