Compare commits
65
Commits
ef0b542e9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e09e8285 | ||
|
|
01072f6788 | ||
|
|
842818ec7e | ||
|
|
7c1c145846 | ||
|
|
aabed2b437 | ||
|
|
e5301f34c4 | ||
|
|
18dde4c9f4 | ||
|
|
1c5c727691 | ||
|
|
3a07eb384f | ||
|
|
cb883ca869 | ||
|
|
3c786ee9d9 | ||
|
|
c20510814e | ||
|
|
a816e56cfa | ||
|
|
c1ac1d659b | ||
|
|
379dee3ea6 | ||
|
|
54d79cdcdf | ||
|
|
619409eabb | ||
|
|
e7de88551d | ||
|
|
f8247ff7af | ||
|
|
2441cef539 | ||
|
|
75055e6f6e | ||
|
|
63df8e9a88 | ||
|
|
704b0dc266 | ||
|
|
b7477306d8 | ||
|
|
02a8379de2 | ||
|
|
f0b5bf75a5 | ||
|
|
60d764045f | ||
|
|
9c4553f3d0 | ||
|
|
b0bad0d273 | ||
|
|
9c3a645368 | ||
|
|
d2b2a04b56 | ||
|
|
7663ca63f6 | ||
|
|
415934e0bf | ||
|
|
dae3e7fd68 | ||
|
|
2c917edf20 | ||
|
|
0be6800d88 | ||
|
|
61f959e1a2 | ||
|
|
a13a3adeef | ||
|
|
8c52946340 | ||
|
|
a628973ed8 | ||
|
|
b32abfb8f6 | ||
|
|
07fea9e57b | ||
|
|
c8832f49b2 | ||
|
|
bd035501b7 | ||
|
|
ecccfe8c70 | ||
|
|
fbd3b7feb0 | ||
|
|
8c3284399e | ||
|
|
5bffd36ff8 | ||
|
|
7267f67d99 | ||
|
|
2b0daaa699 | ||
|
|
c82e6187af | ||
|
|
a41f6f3834 | ||
|
|
ca367364f8 | ||
|
|
e38511126d | ||
|
|
ae8a80484f | ||
|
|
53b87c20fd | ||
|
|
b4fb3b051b | ||
|
|
6c78d23512 | ||
|
|
5e08b78d62 | ||
|
|
43e3316a70 | ||
|
|
1544a7a04b | ||
|
|
c2a1eed2cf | ||
|
|
e5cc42f048 | ||
|
|
78360dfc89 | ||
|
|
21657173e0 |
@@ -26,7 +26,9 @@
|
|||||||
"ui": 0,
|
"ui": 0,
|
||||||
"return_format": "value",
|
"return_format": "value",
|
||||||
"ajax": 0,
|
"ajax": 0,
|
||||||
"placeholder": ""
|
"placeholder": "",
|
||||||
|
"create_options": 0,
|
||||||
|
"save_options": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "field_6478f669004aa",
|
"key": "field_6478f669004aa",
|
||||||
@@ -44,7 +46,10 @@
|
|||||||
},
|
},
|
||||||
"default_value": "",
|
"default_value": "",
|
||||||
"enable_opacity": 0,
|
"enable_opacity": 0,
|
||||||
"return_format": "string"
|
"return_format": "string",
|
||||||
|
"custom_palette_source": "",
|
||||||
|
"palette_colors": "",
|
||||||
|
"show_color_wheel": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "field_6478f68c004ab",
|
"key": "field_6478f68c004ab",
|
||||||
@@ -94,6 +99,35 @@
|
|||||||
"append": "",
|
"append": "",
|
||||||
"maxlength": ""
|
"maxlength": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "field_6a4ad339f75b8",
|
||||||
|
"label": "Subtitle",
|
||||||
|
"name": "subtitle",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "field_60bfda53dc0f2",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 1,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "field_60bfb85a4a420",
|
"key": "field_60bfb85a4a420",
|
||||||
"label": "Intro",
|
"label": "Intro",
|
||||||
@@ -124,22 +158,23 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "field_60bfb8614a421",
|
"key": "field_60bfb8614a421",
|
||||||
"label": "Call to Actions",
|
"label": "Calls to Action",
|
||||||
"name": "call_to_actions",
|
"name": "ctas",
|
||||||
"aria-label": "",
|
"aria-label": "",
|
||||||
"type": "repeater",
|
"type": "repeater",
|
||||||
"instructions": "",
|
"instructions": "",
|
||||||
"required": 0,
|
"required": 0,
|
||||||
"conditional_logic": false,
|
"conditional_logic": 0,
|
||||||
"wrapper": {
|
"wrapper": {
|
||||||
"width": "",
|
"width": "",
|
||||||
"class": "",
|
"class": "",
|
||||||
"id": ""
|
"id": ""
|
||||||
},
|
},
|
||||||
"collapsed": "",
|
"layout": "table",
|
||||||
|
"pagination": 0,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"max": 0,
|
"max": 0,
|
||||||
"layout": "table",
|
"collapsed": "",
|
||||||
"button_label": "Add Call to Action",
|
"button_label": "Add Call to Action",
|
||||||
"rows_per_page": 20,
|
"rows_per_page": 20,
|
||||||
"sub_fields": [
|
"sub_fields": [
|
||||||
@@ -202,7 +237,7 @@
|
|||||||
"name": "hero_vector",
|
"name": "hero_vector",
|
||||||
"aria-label": "",
|
"aria-label": "",
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"instructions": "Optional decorative linework/vector rendered on the right side of the hero.",
|
"instructions": "Optional decorative linework\/vector rendered on the right side of the hero.",
|
||||||
"required": 0,
|
"required": 0,
|
||||||
"conditional_logic": [
|
"conditional_logic": [
|
||||||
[
|
[
|
||||||
@@ -256,5 +291,8 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"description": "",
|
"description": "",
|
||||||
"show_in_rest": 0,
|
"show_in_rest": 0,
|
||||||
"modified": 1782918111
|
"display_title": "",
|
||||||
|
"allow_ai_access": false,
|
||||||
|
"ai_description": "",
|
||||||
|
"modified": 1784082334
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"key": "group_services_list",
|
||||||
|
"title": "Services List",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "field_services_items",
|
||||||
|
"label": "Items",
|
||||||
|
"name": "items",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "repeater",
|
||||||
|
"instructions": "Add 2, 3, or 4 cards. The grid layout adapts to the count.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"layout": "block",
|
||||||
|
"pagination": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"collapsed": "",
|
||||||
|
"button_label": "Add Service",
|
||||||
|
"rows_per_page": 20,
|
||||||
|
"sub_fields": [
|
||||||
|
{
|
||||||
|
"key": "field_services_item_image",
|
||||||
|
"label": "Image",
|
||||||
|
"name": "image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"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",
|
||||||
|
"parent_repeater": "field_services_items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_services_item_heading",
|
||||||
|
"label": "Heading",
|
||||||
|
"name": "heading",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": "",
|
||||||
|
"parent_repeater": "field_services_items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_services_item_description",
|
||||||
|
"label": "Description",
|
||||||
|
"name": "description",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "wysiwyg",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"tabs": "all",
|
||||||
|
"toolbar": "full",
|
||||||
|
"media_upload": 1,
|
||||||
|
"delay": 0,
|
||||||
|
"parent_repeater": "field_services_items"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"location": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"param": "block",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "acf\/services-list"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"menu_order": 0,
|
||||||
|
"position": "normal",
|
||||||
|
"style": "default",
|
||||||
|
"label_placement": "top",
|
||||||
|
"instruction_placement": "label",
|
||||||
|
"hide_on_screen": "",
|
||||||
|
"active": true,
|
||||||
|
"description": "Service cards row. Card count (2-4) determines the layout.",
|
||||||
|
"show_in_rest": 0,
|
||||||
|
"display_title": "",
|
||||||
|
"allow_ai_access": false,
|
||||||
|
"ai_description": "",
|
||||||
|
"modified": 1784992250
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"key": "group_team_grid",
|
||||||
|
"title": "Team Grid",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "field_6a650dee2a34c",
|
||||||
|
"label": "Single Open Mode",
|
||||||
|
"name": "single",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "true_false",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"message": "Check to allow only one bio open at a time",
|
||||||
|
"default_value": 0,
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"ui": 0,
|
||||||
|
"ui_on_text": "",
|
||||||
|
"ui_off_text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_members",
|
||||||
|
"label": "Members",
|
||||||
|
"name": "members",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "repeater",
|
||||||
|
"instructions": "Add team members. Click a card to expand the bio.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"layout": "block",
|
||||||
|
"pagination": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"collapsed": "",
|
||||||
|
"button_label": "Add Team Member",
|
||||||
|
"rows_per_page": 20,
|
||||||
|
"sub_fields": [
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_image",
|
||||||
|
"label": "Image",
|
||||||
|
"name": "image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"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",
|
||||||
|
"parent_repeater": "field_team_grid_members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_name",
|
||||||
|
"label": "Name",
|
||||||
|
"name": "name",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": "",
|
||||||
|
"parent_repeater": "field_team_grid_members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_title",
|
||||||
|
"label": "Title",
|
||||||
|
"name": "title",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": "",
|
||||||
|
"parent_repeater": "field_team_grid_members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_bio",
|
||||||
|
"label": "Bio",
|
||||||
|
"name": "bio",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "wysiwyg",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"tabs": "visual",
|
||||||
|
"toolbar": "basic",
|
||||||
|
"media_upload": 0,
|
||||||
|
"delay": 0,
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"parent_repeater": "field_team_grid_members"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"location": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"param": "block",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "acf\/team-grid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"menu_order": 0,
|
||||||
|
"position": "normal",
|
||||||
|
"style": "default",
|
||||||
|
"label_placement": "top",
|
||||||
|
"instruction_placement": "label",
|
||||||
|
"hide_on_screen": "",
|
||||||
|
"active": true,
|
||||||
|
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
|
||||||
|
"show_in_rest": 0,
|
||||||
|
"display_title": "",
|
||||||
|
"allow_ai_access": false,
|
||||||
|
"ai_description": "",
|
||||||
|
"modified": 1785007821
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
# Services Content Blocks 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 missing content-area components for the Mapping services page (and any other service sub-page), matching `Mapping-Services.png`. The hero, header, and footer are already in place and must not be touched.
|
||||||
|
|
||||||
|
**Architecture:** One new ACF block (`services-list`) that renders a row of cards. Card count is editor-driven (2, 3, or 4); card size and grid layout adapt to the count. The orange horizontal line that bisects the images is rendered by the block itself as a full-bleed SVG background. The closing "At CWC, mapping is about more than data…" block reuses the existing `pull-quote` block — no new build needed. Editor composes the page in Gutenberg by adding `Services List` and `Pull Quote` blocks to any service sub-page; `page.php` already calls `the_content()`, so no template change is required.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Tabs for PHP indentation (project standard).
|
||||||
|
- `static/dist/theme.css` is committed (not gitignored) per project convention.
|
||||||
|
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
|
||||||
|
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
|
||||||
|
- Reuse the existing post-title accent pattern (blue heading + orange `::after` underline from `.post-title h1` in `styles/base/misc.css`) for consistency. Do not introduce a new accent style.
|
||||||
|
- Reuse the existing `pull-quote` block for the closing statement — no duplicate block.
|
||||||
|
- Page template `page.php` is not modified. The page-children block is not used.
|
||||||
|
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
|
||||||
|
- Tailwind v4 picks up class names from source files at build time. The dynamic `lg:grid-cols-N` class needs to literally appear in the source PHP for Tailwind to emit the rule.
|
||||||
|
- The `static/img/services-list-line.svg` is a fixed asset; do not make it editor-uploadable.
|
||||||
|
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Created/Modified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `views/blocks/services-list/services-list.php` | Block template (PHP) — renders the row of cards. | Create |
|
||||||
|
| `views/blocks/services-list/services-list.css` | Block styles (CSS) — layout, headings, bisecting line. | Create |
|
||||||
|
| `views/blocks/services-list/block.json` | Block registration metadata (name, category, icon, render template). | Create |
|
||||||
|
| `acf/group_services_list.json` | ACF field group for the `acf/services-list` block. | Create |
|
||||||
|
| `static/img/services-list-line.svg` | Decorative orange horizontal line SVG that bisects the images. | Create |
|
||||||
|
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
|
||||||
|
| `tests/services-page.spec.js` | Playwright tests for the rendered block. | Create |
|
||||||
|
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ACF Field Group
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `acf/group_services_list.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: ACF group with `key: "group_services_list"`, location scoped to `block == acf/services-list`, fields: `items` (repeater) → `image`, `heading`, `description`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the ACF field group JSON file**
|
||||||
|
|
||||||
|
Create the file `acf/group_services_list.json` with the following exact content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "group_services_list",
|
||||||
|
"title": "Services List",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "field_services_items",
|
||||||
|
"label": "Items",
|
||||||
|
"name": "items",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "repeater",
|
||||||
|
"instructions": "Add 2, 3, or 4 cards. The grid layout adapts to the count.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"layout": "block",
|
||||||
|
"pagination": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"collapsed": "",
|
||||||
|
"button_label": "Add Service",
|
||||||
|
"rows_per_page": 20,
|
||||||
|
"sub_fields": [
|
||||||
|
{
|
||||||
|
"key": "field_services_item_image",
|
||||||
|
"label": "Image",
|
||||||
|
"name": "image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"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_services_item_heading",
|
||||||
|
"label": "Heading",
|
||||||
|
"name": "heading",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_services_item_description",
|
||||||
|
"label": "Description",
|
||||||
|
"name": "description",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "textarea",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"placeholder": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"rows": 4,
|
||||||
|
"new_lines": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"location": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"param": "block",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "acf/services-list"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"menu_order": 0,
|
||||||
|
"position": "normal",
|
||||||
|
"style": "default",
|
||||||
|
"label_placement": "top",
|
||||||
|
"instruction_placement": "label",
|
||||||
|
"hide_on_screen": "",
|
||||||
|
"active": true,
|
||||||
|
"description": "Service cards row. Card count (2-4) determines the layout.",
|
||||||
|
"show_in_rest": 0,
|
||||||
|
"display_title": "",
|
||||||
|
"allow_ai_access": false,
|
||||||
|
"ai_description": "",
|
||||||
|
"modified": 1753305600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the JSON parses**
|
||||||
|
|
||||||
|
Run: `node -e "JSON.parse(require('fs').readFileSync('acf/group_services_list.json','utf8')); console.log('OK')"`
|
||||||
|
Expected: prints `OK` and exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add acf/group_services_list.json
|
||||||
|
git commit -m "✨ feat: Add ACF field group for services-list block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Decorative SVG Asset
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `static/img/services-list-line.svg`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: A 1600×8 viewBox SVG with a 2px-tall orange `#F26B53` rectangle, `preserveAspectRatio="none"`. Reference URL: `/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the SVG asset**
|
||||||
|
|
||||||
|
Create the file `static/img/services-list-line.svg` with the following exact content:
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 8" preserveAspectRatio="none">
|
||||||
|
<rect x="0" y="3" width="1600" height="2" fill="#F26B53" />
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the SVG is well-formed**
|
||||||
|
|
||||||
|
Run: `node -e "const fs=require('fs'); const s=fs.readFileSync('static/img/services-list-line.svg','utf8'); if(!s.includes('viewBox=\"0 0 1600 8\"')||!s.includes('fill=\"#F26B53\"')) { process.exit(1); } console.log('OK')"`
|
||||||
|
Expected: prints `OK` and exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add static/img/services-list-line.svg
|
||||||
|
git commit -m "✨ feat: Add services-list decorative line SVG"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Block Registration JSON
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/services-list/block.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `block.json` declaring `acf/services-list` as the block name, `services-list.php` as the render template, `grid-view` icon, `common` category. `regACFBlocks()` in `functions.php` will pick this up automatically — no PHP registration code change required.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block.json file**
|
||||||
|
|
||||||
|
Create the file `views/blocks/services-list/block.json` with the following exact content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "acf/services-list",
|
||||||
|
"title": "Services List",
|
||||||
|
"description": "A row of service cards. Card count (2, 3, or 4) determines the layout.",
|
||||||
|
"category": "common",
|
||||||
|
"icon": "grid-view",
|
||||||
|
"keywords": ["services", "cards", "mapping"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "services-list.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the JSON parses**
|
||||||
|
|
||||||
|
Run: `node -e "JSON.parse(require('fs').readFileSync('views/blocks/services-list/block.json','utf8')); console.log('OK')"`
|
||||||
|
Expected: prints `OK` and exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/services-list/block.json
|
||||||
|
git commit -m "✨ feat: Add services-list block.json registration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Block CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/services-list/services-list.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: BEM-styled CSS for `.services-list`, `__line`, `__grid`, `__card`, `__media`, `__heading`, `__desc`. Reuses `text-cwc-blue-01` and `var(--color-secondary)` from existing tokens. Renders the bisecting line via `background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg')`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block CSS file**
|
||||||
|
|
||||||
|
Create the file `views/blocks/services-list/services-list.css` with the following exact content:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Services List block */
|
||||||
|
|
||||||
|
.services-list {
|
||||||
|
--services-line-top: 25%;
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: var(--services-line-top);
|
||||||
|
height: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__media {
|
||||||
|
@apply overflow-hidden rounded-md bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
@apply w-full h-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__heading {
|
||||||
|
@apply text-cwc-blue-01 text-24px font-bold leading-none mt-6 mb-4;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
@apply text-gray-600 text-16px font-light leading-snug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.services-list__line {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the CSS contains the expected classes**
|
||||||
|
|
||||||
|
The brief uses CSS-nesting shorthand (`&__line`, `&__grid`, etc.) for 6 of the 7 BEM children, and a literal selector (`.services-list__line`) for the one inside the `@media` block. A naive grep for the literal class names will undercount. Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cE "&__(line|grid|card|media|img|heading|desc)|services-list__(line|grid|card|media|img|heading|desc)" views/blocks/services-list/services-list.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `8` (7 nested `&__` selectors + 1 literal `.services-list__line` inside the `@media` block).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/services-list/services-list.css
|
||||||
|
git commit -m "✨ feat: Add services-list block CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Block Template (PHP)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/services-list/services-list.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `get_field( 'items' )` → array of items each with `image` (array: `url`, `alt`), `heading` (string), `description` (string).
|
||||||
|
- Produces: A `<section class="services-list ...">` element containing the bisecting `__line` div, a `container` with a grid div, and a `<article class="services-list__card">` per item. Computes the dynamic `lg:grid-cols-N` class from item count.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block PHP template**
|
||||||
|
|
||||||
|
Create the file `views/blocks/services-list/services-list.php` with the following exact content (tabs for indentation):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Block Name: Services List
|
||||||
|
*
|
||||||
|
* A row of service cards. Card count (2, 3, or 4) determines the layout.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$items = get_field( 'items' );
|
||||||
|
|
||||||
|
if ( empty( $items ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = count( $items );
|
||||||
|
$grid_cols = max( 2, min( 4, (int) $count ) );
|
||||||
|
$grid_class = 'lg:grid-cols-' . $grid_cols;
|
||||||
|
$line_url = '/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg';
|
||||||
|
|
||||||
|
$classes = 'services-list mx-break-out relative ' . $grid_class;
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section <?php echo wp_kses_post( $wrapper ); ?>>
|
||||||
|
<div class="services-list__line" aria-hidden="true" style="background-image: url('<?php echo esc_url( $line_url ); ?>');"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="services-list__grid grid grid-cols-1 <?php echo esc_attr( $grid_class ); ?> gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $items as $item ) : ?>
|
||||||
|
<article class="services-list__card">
|
||||||
|
<?php if ( ! empty( $item['image'] ) ) : ?>
|
||||||
|
<figure class="services-list__media aspect-square">
|
||||||
|
<img
|
||||||
|
class="services-list__img"
|
||||||
|
src="<?php echo esc_url( $item['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $item['image']['alt'] ?? '' ); ?>"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['heading'] ) ) : ?>
|
||||||
|
<h3 class="services-list__heading">
|
||||||
|
<?php echo wp_kses_post( $item['heading'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['description'] ) ) : ?>
|
||||||
|
<div class="services-list__desc">
|
||||||
|
<?php echo wp_kses_post( $item['description'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint the PHP file with PHPCS**
|
||||||
|
|
||||||
|
Run: `composer exec phpcs -- views/blocks/services-list/services-list.php`
|
||||||
|
Expected: exits 0 with no errors. If there are pre-existing project-wide issues unrelated to this file, fix only the ones in this file.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the dynamic grid class is in the source**
|
||||||
|
|
||||||
|
Run: `grep -E "lg:grid-cols-\$grid_cols|lg:grid-cols-' \. \$grid_cols" views/blocks/services-list/services-list.php`
|
||||||
|
Expected: at least one match (Tailwind must see the literal class name in the source to emit the rule).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/services-list/services-list.php
|
||||||
|
git commit -m "✨ feat: Add services-list block PHP template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: CSS Import Wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `styles/blocks/index.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `views/blocks/services-list/services-list.css` (from Task 4).
|
||||||
|
- Produces: A new `@import` line at the end of `styles/blocks/index.css` so Tailwind's build picks up the block's CSS.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to the block index CSS**
|
||||||
|
|
||||||
|
Append the following line at the end of `styles/blocks/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
After the edit, the file should end with:
|
||||||
|
|
||||||
|
```
|
||||||
|
@import '../../views/blocks/contact-info/contact-info.css';
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the import is present**
|
||||||
|
|
||||||
|
Run: `grep -c "services-list/services-list.css" styles/blocks/index.css`
|
||||||
|
Expected: `1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add styles/blocks/index.css
|
||||||
|
git commit -m "🔧 chore: Import services-list block CSS into theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Build the CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `static/dist/theme.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The new import in `styles/blocks/index.css` (from Task 6) and the block CSS (from Task 4).
|
||||||
|
- Produces: A rebuilt `static/dist/theme.css` that includes the `.services-list*` rules and the dynamic `lg:grid-cols-2`, `lg:grid-cols-3`, `lg:grid-cols-4` utilities.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the production Tailwind build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: completes with no errors. The output file `static/dist/theme.css` is updated in place.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the services-list CSS classes are in the built output**
|
||||||
|
|
||||||
|
The build unwraps CSS-nesting `&__heading` into `__heading.services-list` (no literal `.services-list__heading` is emitted). Use a fixed-string search that matches the un-nested form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cF "__heading.services-list" static/dist/theme.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `1` or more. (Note: on Windows Git-Bash, prefer `grep -F` because POSIX `\\:` escapes are stripped before reaching grep.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the dynamic grid classes are in the built output**
|
||||||
|
|
||||||
|
Same Windows-shell caveat. Use fixed-string search:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cF "lg\:grid-cols-2" static/dist/theme.css
|
||||||
|
grep -cF "lg\:grid-cols-3" static/dist/theme.css
|
||||||
|
grep -cF "lg\:grid-cols-4" static/dist/theme.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each returns `1` or more.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the rebuilt dist**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add -f static/dist/theme.css
|
||||||
|
git commit -m "🎨 build: Rebuild dist with services-list block styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Playwright Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/services-page.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The rendered `views/blocks/services-list/services-list.php` template (Tasks 4 + 5) on the WordPress page `/services/mapping/`.
|
||||||
|
- Produces: Three tests:
|
||||||
|
1. Card count, image, and heading visibility per block (expects 2 + 4 cards across two block instances).
|
||||||
|
2. Bisecting `__line` is present and `aria-hidden="true"`.
|
||||||
|
3. No axe-core a11y violations scoped to `.services-list`.
|
||||||
|
|
||||||
|
> **Prerequisite:** the test depends on the mapping page being composed in Gutenberg with two `Services List` blocks (2 items + 4 items) and a `Pull Quote` block. That composition is the editor's responsibility and is not part of this plan. The tests should be authored against the *intended* state, not a placeholder state.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the test spec file**
|
||||||
|
|
||||||
|
Create the file `tests/services-page.spec.js` with the following exact content:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Services List block on /services/mapping/", () => {
|
||||||
|
test("renders the heading and image for each card", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
|
||||||
|
const blocks = page.locator(".services-list");
|
||||||
|
await expect(blocks).toHaveCount(2);
|
||||||
|
|
||||||
|
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
|
||||||
|
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
|
||||||
|
|
||||||
|
const firstCard = blocks.nth(0).locator(".services-list__card").first();
|
||||||
|
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bisecting line is present and aria-hidden", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const line = page.locator(".services-list__line").first();
|
||||||
|
await expect(line).toBeAttached();
|
||||||
|
await expect(line).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the services-list block", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".services-list")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests**
|
||||||
|
|
||||||
|
Run: `npx playwright test tests/services-page.spec.js`
|
||||||
|
Expected: all 3 tests pass. If tests fail because the mapping page has not yet been composed in the editor, that's an expected prerequisite — note the prerequisite in the final report and skip the test run.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add tests/services-page.spec.js
|
||||||
|
git commit -m "✅ test: Add Playwright tests for services-list block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:**
|
||||||
|
- One new ACF block (`services-list`) — covered by Tasks 1, 3, 4, 5.
|
||||||
|
- Repeater of `items` with `image` / `heading` / `description` — covered by Task 1.
|
||||||
|
- Card count drives grid columns (2/3/4) — covered by Task 5 (PHP computes the class) and Task 7 (Tailwind emits the rule).
|
||||||
|
- Orange bisecting line, full-bleed, behind the cards, hidden on mobile — covered by Tasks 2 (SVG), 4 (CSS with `inset-inline: 0`, `z-index: 1` on line, `z-index: 2` on grid, media query to hide on `<lg`).
|
||||||
|
- Reuse `pull-quote` for closing — explicitly out of scope (no code change needed).
|
||||||
|
- Page template not modified — confirmed; `page.php` is not in any file list.
|
||||||
|
- `header.php` / `footer.php` / page hero not touched — confirmed.
|
||||||
|
- Reuse existing design tokens (`text-cwc-blue-01`, `var(--color-secondary)`, brand orange `#F26B53`) — confirmed in Tasks 4 and 2.
|
||||||
|
- Playwright tests covering count, image, heading, line, a11y — covered by Task 8.
|
||||||
|
- Dist rebuild — covered by Task 7.
|
||||||
|
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output. The only conditional is in Task 8 Step 2 — explicitly called out as a prerequisite (editor must compose the page in Gutenberg), not a placeholder.
|
||||||
|
- **Type consistency:** ACF field `name: "items"` is used in Task 5 (`get_field( 'items' )`). Sub-field names `image`, `heading`, `description` are used in Task 1 (defined) and Task 5 (consumed). Class names `services-list`, `__line`, `__grid`, `__card`, `__media`, `__img`, `__heading`, `__desc` are used in Task 4 (defined in CSS) and Task 5 (rendered in markup) and Task 8 (asserted in tests) — consistent.
|
||||||
|
- **Ambiguity check:** "Editor adds 2 instances of the block on the mapping page" is the chosen approach (per spec §1 and design Q&A). "Bisecting line" is a single orange SVG, 8px tall, positioned at `top: 25%` of the block wrapper, hidden on mobile. Both are explicit.
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# Team Grid Block 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 a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
|
||||||
|
|
||||||
|
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Tabs for PHP indentation (project standard).
|
||||||
|
- `static/dist/theme.css` is committed (not gitignored) per project convention.
|
||||||
|
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
|
||||||
|
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
|
||||||
|
- Reuse existing design tokens where possible (e.g. `text-cwc-blue-01`, `var(--color-secondary)`).
|
||||||
|
- The headshot uses `rounded-md` to match other image cards in the project.
|
||||||
|
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
|
||||||
|
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
|
||||||
|
- `page.php` is not modified.
|
||||||
|
- No JavaScript files are added.
|
||||||
|
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
|
||||||
|
- Tailwind v4 picks up class names from source files at build time. The dynamic class names used in the CSS (`grid-cols-1`, `sm:grid-cols-2`, `lg:grid-cols-3`, `w-32 h-32`, etc.) must all be literal in the source for the build to emit them.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Created/Modified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
|
||||||
|
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
|
||||||
|
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
|
||||||
|
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
|
||||||
|
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
|
||||||
|
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
|
||||||
|
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ACF Field Group
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `acf/group_team_grid.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: ACF group with `key: "group_team_grid"`, location scoped to `block == acf/team-grid`, fields: `members` (repeater) → `image`, `name`, `title`, `bio`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the ACF field group JSON file**
|
||||||
|
|
||||||
|
Create the file `acf/group_team_grid.json` with the following exact content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "group_team_grid",
|
||||||
|
"title": "Team Grid",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_members",
|
||||||
|
"label": "Members",
|
||||||
|
"name": "members",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "repeater",
|
||||||
|
"instructions": "Add team members. Click a card to expand the bio.",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"layout": "block",
|
||||||
|
"pagination": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"collapsed": "",
|
||||||
|
"button_label": "Add Team Member",
|
||||||
|
"rows_per_page": 20,
|
||||||
|
"sub_fields": [
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_image",
|
||||||
|
"label": "Image",
|
||||||
|
"name": "image",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "image",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"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_team_grid_member_name",
|
||||||
|
"label": "Name",
|
||||||
|
"name": "name",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_title",
|
||||||
|
"label": "Title",
|
||||||
|
"name": "title",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "text",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"maxlength": "",
|
||||||
|
"allow_in_bindings": 0,
|
||||||
|
"placeholder": "",
|
||||||
|
"prepend": "",
|
||||||
|
"append": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "field_team_grid_member_bio",
|
||||||
|
"label": "Bio",
|
||||||
|
"name": "bio",
|
||||||
|
"aria-label": "",
|
||||||
|
"type": "wysiwyg",
|
||||||
|
"instructions": "",
|
||||||
|
"required": 0,
|
||||||
|
"conditional_logic": 0,
|
||||||
|
"wrapper": {
|
||||||
|
"width": "",
|
||||||
|
"class": "",
|
||||||
|
"id": ""
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"tabs": "visual",
|
||||||
|
"toolbar": "basic",
|
||||||
|
"media_upload": 0,
|
||||||
|
"delay": 0,
|
||||||
|
"allow_in_bindings": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"location": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"param": "block",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "acf/team-grid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"menu_order": 0,
|
||||||
|
"position": "normal",
|
||||||
|
"style": "default",
|
||||||
|
"label_placement": "top",
|
||||||
|
"instruction_placement": "label",
|
||||||
|
"hide_on_screen": "",
|
||||||
|
"active": true,
|
||||||
|
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
|
||||||
|
"show_in_rest": 0,
|
||||||
|
"display_title": "",
|
||||||
|
"allow_ai_access": false,
|
||||||
|
"ai_description": "",
|
||||||
|
"modified": 1753401600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the JSON parses**
|
||||||
|
|
||||||
|
Run: `node -e "JSON.parse(require('fs').readFileSync('acf/group_team_grid.json','utf8')); console.log('OK')"`
|
||||||
|
Expected: prints `OK` and exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add acf/group_team_grid.json
|
||||||
|
git commit -m "✨ feat: Add ACF field group for team-grid block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Block Registration JSON
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/team-grid/block.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `block.json` declaring `acf/team-grid` as the block name, `team-grid.php` as the render template, `groups` icon, `common` category. `regACFBlocks()` in `functions.php` will pick this up automatically.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block.json file**
|
||||||
|
|
||||||
|
Create the file `views/blocks/team-grid/block.json` with the following exact content:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "acf/team-grid",
|
||||||
|
"title": "Team Grid",
|
||||||
|
"description": "A responsive grid of team members. Click a card to expand the bio.",
|
||||||
|
"category": "common",
|
||||||
|
"icon": "groups",
|
||||||
|
"keywords": ["team", "people", "staff", "bio"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "team-grid.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the JSON parses**
|
||||||
|
|
||||||
|
Run: `node -e "JSON.parse(require('fs').readFileSync('views/blocks/team-grid/block.json','utf8')); console.log('OK')"`
|
||||||
|
Expected: prints `OK` and exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/team-grid/block.json
|
||||||
|
git commit -m "✨ feat: Add team-grid block.json registration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Block CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/team-grid/team-grid.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: BEM-styled CSS for `.team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`. Reuses `text-cwc-blue-01` for the name. Implements the `grid-template-rows: 0fr → 1fr` animation for the bio.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block CSS file**
|
||||||
|
|
||||||
|
Create the file `views/blocks/team-grid/team-grid.css` with the following exact content:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Team Grid block */
|
||||||
|
|
||||||
|
.team-grid {
|
||||||
|
&__card {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
@apply flex flex-col items-center text-center cursor-pointer list-none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::marker {
|
||||||
|
display: none;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__photo {
|
||||||
|
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
@apply text-gray-600 text-16px font-light italic mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details[open] &__bio-wrap {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
@apply text-gray-700 text-16px font-light leading-snug pt-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the BEM children are present**
|
||||||
|
|
||||||
|
Run: `grep -cE "&__(card|details|summary|photo|name|title|bio-wrap|bio-inner|bio)\b" views/blocks/team-grid/team-grid.css`
|
||||||
|
Expected: `9` (one match per BEM child).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/team-grid/team-grid.css
|
||||||
|
git commit -m "✨ feat: Add team-grid block CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Block Template (PHP)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `views/blocks/team-grid/team-grid.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `get_field( 'members' )` → array of members each with `image` (array: `url`, `alt`), `name` (string), `title` (string), `bio` (wysiwyg HTML).
|
||||||
|
- Produces: A `<section class="team-grid ...">` element with a `__inner` grid div, and an `<article class="team-grid__card">` per member. Each card contains a `<details>` with a `<summary>` (photo + name + title) and a `__bio-wrap` containing the bio.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the block PHP template**
|
||||||
|
|
||||||
|
Create the file `views/blocks/team-grid/team-grid.php` with the following exact content (tabs for indentation):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Block Name: Team Grid
|
||||||
|
*
|
||||||
|
* A responsive grid of team members. Each card uses a native <details>
|
||||||
|
* element so the bio expands when the photo, name, or title is clicked.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$members = get_field( 'members' );
|
||||||
|
|
||||||
|
if ( empty( $members ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = 'team-grid container my-section mx-auto';
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section <?php echo wp_kses_post( $wrapper ); ?>>
|
||||||
|
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $members as $member ) : ?>
|
||||||
|
<article class="team-grid__card">
|
||||||
|
<details class="team-grid__details">
|
||||||
|
<summary class="team-grid__summary">
|
||||||
|
<?php if ( ! empty( $member['image'] ) ) : ?>
|
||||||
|
<img
|
||||||
|
class="team-grid__photo"
|
||||||
|
src="<?php echo esc_url( $member['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['name'] ) ) : ?>
|
||||||
|
<h3 class="team-grid__name">
|
||||||
|
<?php echo wp_kses_post( $member['name'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['title'] ) ) : ?>
|
||||||
|
<p class="team-grid__title">
|
||||||
|
<?php echo wp_kses_post( $member['title'] ); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="team-grid__bio-wrap">
|
||||||
|
<div class="team-grid__bio-inner">
|
||||||
|
<?php if ( ! empty( $member['bio'] ) ) : ?>
|
||||||
|
<div class="team-grid__bio">
|
||||||
|
<?php echo wp_kses_post( $member['bio'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint the PHP file with PHPCS**
|
||||||
|
|
||||||
|
Run: `composer exec phpcs -- views/blocks/team-grid/team-grid.php`
|
||||||
|
Expected: exits 0 with no errors. If PHPCS reports alignment warnings on the assignment block (similar to the Task 5 services-list fix), run `composer exec phpcbf -- views/blocks/team-grid/team-grid.php` to auto-fix, then re-run PHPCS to confirm exit 0. Whitespace-only changes are acceptable; do not modify logic.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the dynamic utility classes are in the source**
|
||||||
|
|
||||||
|
Run: `grep -E "grid-cols-1|sm:grid-cols-2|lg:grid-cols-3|w-32|w-40|w-48" views/blocks/team-grid/team-grid.php`
|
||||||
|
Expected: at least 3 matches (Tailwind v4 must see the literal class names in the source to emit the rules).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add views/blocks/team-grid/team-grid.php
|
||||||
|
git commit -m "✨ feat: Add team-grid block PHP template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: CSS Import Wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `styles/blocks/index.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `views/blocks/team-grid/team-grid.css` (from Task 3).
|
||||||
|
- Produces: A new `@import` line at the end of `styles/blocks/index.css` so Tailwind's build picks up the block's CSS.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to the block index CSS**
|
||||||
|
|
||||||
|
Append the following line at the end of `styles/blocks/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import '../../views/blocks/team-grid/team-grid.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
After the edit, the file should end with:
|
||||||
|
|
||||||
|
```
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
@import '../../views/blocks/team-grid/team-grid.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the import is present**
|
||||||
|
|
||||||
|
Run: `grep -cF "team-grid/team-grid.css" styles/blocks/index.css`
|
||||||
|
Expected: `1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add styles/blocks/index.css
|
||||||
|
git commit -m "🔧 chore: Import team-grid block CSS into theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Build the CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `static/dist/theme.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The new import in `styles/blocks/index.css` (from Task 5) and the block CSS (from Task 3).
|
||||||
|
- Produces: A rebuilt `static/dist/theme.css` that includes the `.team-grid*` rules and the responsive `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` utilities.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the production Tailwind build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: completes with no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the team-grid CSS classes are in the built output**
|
||||||
|
|
||||||
|
Tailwind v4 with `--optimize` **preserves** CSS-nesting rather than unwrapping `&__name` into `__name.team-grid`. Use fixed-string search (Windows-safe) that matches the preserved form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cF "&__name" static/dist/theme.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `1` or more (the `&__name` rule is nested inside `.team-grid { ... }` in the dist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the responsive grid utilities are in the built output**
|
||||||
|
|
||||||
|
Same Windows-safe form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -cF "sm\:grid-cols-2" static/dist/theme.css
|
||||||
|
grep -cF "lg\:grid-cols-3" static/dist/theme.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each returns `1` or more.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the rebuilt dist**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add -f static/dist/theme.css
|
||||||
|
git commit -m "🎨 build: Rebuild dist with team-grid block styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Playwright Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/team-page.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: The rendered `views/blocks/team-grid/team-grid.php` template (Tasks 3 + 4) on the WordPress page `/team/`.
|
||||||
|
- Produces: Three tests:
|
||||||
|
1. Photo, name, and title visible for the first card.
|
||||||
|
2. Bio is closed by default; clicking the summary opens the details and reveals the bio; clicking again closes it.
|
||||||
|
3. No axe-core a11y violations scoped to `.team-grid`.
|
||||||
|
|
||||||
|
> **Prerequisite:** the test depends on the Team page being composed in Gutenberg with at least one `Team Grid` block containing one or more members. That composition is the editor's responsibility and is not part of this plan. The tests should be authored against the intended state, not a placeholder state.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the test spec file**
|
||||||
|
|
||||||
|
Create the file `tests/team-page.spec.js` with the following exact content:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Team Grid block on /team/", () => {
|
||||||
|
test("renders the photo, name, and title for each member", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const cards = page.locator(".team-grid__card");
|
||||||
|
await expect(cards.first()).toBeVisible();
|
||||||
|
|
||||||
|
const firstCard = cards.first();
|
||||||
|
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bio is hidden by default and expands on summary click", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const firstDetails = page.locator(".team-grid__details").first();
|
||||||
|
// Bio is closed by default.
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
|
||||||
|
// Click the summary; the details should open and the bio should be visible.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).toHaveAttribute("open", "");
|
||||||
|
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
|
||||||
|
|
||||||
|
// Click again; the details should close.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the team-grid block", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".team-grid")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests**
|
||||||
|
|
||||||
|
Run: `npx playwright test tests/team-page.spec.js`
|
||||||
|
Expected: all 3 tests pass. If tests fail because the Team page has not yet been composed in the editor, that's an expected prerequisite — note the prerequisite in the final report and skip the test run.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
|
||||||
|
git add tests/team-page.spec.js
|
||||||
|
git commit -m "✅ test: Add Playwright tests for team-grid block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:**
|
||||||
|
- One new ACF block (`team-grid`) — covered by Tasks 1, 2, 3, 4.
|
||||||
|
- Repeater of `members` with `image` / `name` / `title` / `bio` — covered by Task 1.
|
||||||
|
- Responsive grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` — covered by Task 4 (PHP) and Task 6 (Tailwind emits the rules).
|
||||||
|
- Rounded-rectangle headshots via `rounded-md` — covered by Task 3 (CSS).
|
||||||
|
- Native `<details>`/`<summary>` click-to-expand — covered by Task 4 (PHP) and Task 3 (CSS).
|
||||||
|
- `grid-template-rows: 0fr → 1fr` animation — covered by Task 3 (CSS).
|
||||||
|
- No built-in background — explicitly out of scope (Section block wrapper, editor's responsibility).
|
||||||
|
- Page template not modified — confirmed; `page.php` is not in any file list.
|
||||||
|
- `header.php` / `footer.php` / page hero not touched — confirmed.
|
||||||
|
- Reuse existing design tokens (`text-cwc-blue-01`, `rounded-md`) — covered by Tasks 1, 3, 4.
|
||||||
|
- Playwright tests covering photo + name + title, summary click toggles, and a11y — covered by Task 7.
|
||||||
|
- Dist rebuild — covered by Task 6.
|
||||||
|
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output. The only conditional is in Task 7 Step 2 — explicitly called out as a prerequisite (editor must compose the page in Gutenberg), not a placeholder.
|
||||||
|
- **Type consistency:** ACF field `name: "members"` is used in Task 4 (`get_field( 'members' )`). Sub-field names `image`, `name`, `title`, `bio` are used in Task 1 (defined) and Task 4 (consumed). Class names `team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio` are used in Task 3 (defined in CSS) and Task 4 (rendered in markup) and Task 7 (asserted in tests) — consistent.
|
||||||
|
- **Ambiguity check:** "Rounded rectangle" is concretely specified as `rounded-md`. "Opens vertically" is concretely specified as `grid-template-rows: 0fr → 1fr` with 300ms transition. "Responsive grid" is concretely specified as `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`. "No built-in background" is concretely scoped to the Section block wrapper (editor's responsibility, not this block's). All are explicit.
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Services Content Blocks Design
|
||||||
|
|
||||||
|
> **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 missing content-area components for the Mapping services page (and any other service sub-page), matching `Mapping-Services.png`. The hero, header, and footer are already in place and must not be touched.
|
||||||
|
|
||||||
|
**Architecture:** One new ACF block (`services-list`) that renders a row of cards. Card count is editor-driven (2, 3, or 4); card size and grid layout adapt to the count. The orange horizontal line that bisects the images is rendered by the block itself as a full-bleed SVG background. The closing "At CWC, mapping is about more than data…" block reuses the existing `pull-quote` block — no new build needed. Editor composes the page in Gutenberg by adding `Services List` and `Pull Quote` blocks to any service sub-page; `page.php` already calls `the_content()`, so no template change is required.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Tabs for PHP indentation (project standard).
|
||||||
|
- `static/dist/theme.css` is committed (not gitignored) per project convention.
|
||||||
|
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
|
||||||
|
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
|
||||||
|
- Reuse the existing post-title accent pattern (blue heading + orange `::after` underline from `.post-title h1` in `styles/base/misc.css`) for consistency. Do not introduce a new accent style.
|
||||||
|
- Reuse the existing `pull-quote` block for the closing statement — no duplicate block.
|
||||||
|
- Page template `page.php` is not modified. The page-children block is not used.
|
||||||
|
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
|
||||||
|
|
||||||
|
## Reference Mockup
|
||||||
|
|
||||||
|
`Mapping-Services.png` shows three content-area sections on the Mapping services page (URL `/services/mapping/`):
|
||||||
|
|
||||||
|
1. **Featured services row** — 2 large cards side-by-side. Each card: image (top), blue heading with orange underline, intro paragraph. In the mockup, the orange horizontal line cuts through the middle of the images.
|
||||||
|
2. **Service grid** — 4 small cards in a 4-up grid. Each card: square image, blue heading with orange underline, short description. The orange line again bisects the images.
|
||||||
|
3. **Closing statement** — light-blue full-bleed section with decorative linework, centered text "At CWC, mapping is about more than data. It is about helping people understand and make decisions about their land and communities." with "more than data." colored (likely the orange secondary accent).
|
||||||
|
|
||||||
|
The mockup shows a single orange horizontal line that bleeds past the container on both sides, intersecting both rows of images at their vertical midpoint.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Created/Modified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `views/blocks/services-list/` | Folder for the new ACF block. | Create |
|
||||||
|
| `views/blocks/services-list/services-list.php` | Block template (PHP). | Create |
|
||||||
|
| `views/blocks/services-list/services-list.css` | Block styles (CSS). | Create |
|
||||||
|
| `views/blocks/services-list/block.json` | Block registration (ACF block name, category, icon). | Create |
|
||||||
|
| `acf/group_services_list.json` | ACF field group for the `acf/services-list` block. | Create |
|
||||||
|
| `static/img/services-list-line.svg` | Decorative orange horizontal line SVG that bisects the images. | Create |
|
||||||
|
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
|
||||||
|
| `tests/services-page.spec.js` | Playwright tests for the rendered block. | Create |
|
||||||
|
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Details
|
||||||
|
|
||||||
|
### 1. New ACF block: `services-list`
|
||||||
|
|
||||||
|
**Block name (registration key):** `acf/services-list`
|
||||||
|
**Editor title:** "Services List"
|
||||||
|
**Category:** common
|
||||||
|
**Description (in block.json):** "A row of service cards. Card count (2-4) determines the layout."
|
||||||
|
|
||||||
|
#### ACF field group (`acf/group_services_list.json`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Group key: group_services_list
|
||||||
|
Group title: Services List
|
||||||
|
Position: normal
|
||||||
|
Menu order: 0
|
||||||
|
Location: block == acf/services-list
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `items` (repeater, layout: block, button label: "Add Service")
|
||||||
|
- `image` (image, return_format: array, required: 0) — Card image.
|
||||||
|
- `heading` (text, required: 0) — Card title.
|
||||||
|
- `description` (textarea, rows: 4, required: 0) — Card body copy.
|
||||||
|
|
||||||
|
The block is intentionally minimal. No per-item "size" select, no global layout toggle, no CTA field. The grid layout derives from the item count alone.
|
||||||
|
|
||||||
|
#### Block template (`views/blocks/services-list/services-list.php`)
|
||||||
|
|
||||||
|
Top of file:
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$items = get_field( 'items' );
|
||||||
|
if ( empty( $items ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = count( $items );
|
||||||
|
|
||||||
|
// Grid columns by count: 2 -> lg:grid-cols-2, 3 -> lg:grid-cols-3, 4 -> lg:grid-cols-4
|
||||||
|
$grid_class = 'lg:grid-cols-' . max( 2, min( 4, (int) $count ) );
|
||||||
|
|
||||||
|
$classes = "services-list mx-break-out relative $grid_class";
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
```
|
||||||
|
|
||||||
|
Render:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="services-list ..."> <!-- the wrapper from blockWrapperAttributes -->
|
||||||
|
<div class="services-list__line" aria-hidden="true"></div> <!-- full-bleed orange line, see CSS -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="services-list__grid grid grid-cols-1 gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $items as $item ) : ?>
|
||||||
|
<article class="services-list__card">
|
||||||
|
<?php if ( ! empty( $item['image'] ) ) : ?>
|
||||||
|
<figure class="services-list__media aspect-square overflow-hidden rounded-md">
|
||||||
|
<img
|
||||||
|
src="<?php echo esc_url( $item['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $item['image']['alt'] ?? '' ); ?>"
|
||||||
|
class="services-list__img w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['heading'] ) ) : ?>
|
||||||
|
<h3 class="services-list__heading">
|
||||||
|
<?php echo wp_kses_post( $item['heading'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['description'] ) ) : ?>
|
||||||
|
<div class="services-list__desc">
|
||||||
|
<?php echo wp_kses_post( $item['description'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `lg:grid-cols-N` class is generated dynamically. Tailwind v4 picks up the class from the source file and emits the rule. No safelist needed.
|
||||||
|
- Card aspect ratio: `aspect-square` for all counts. Mockup shows square images for both the 2-up and 4-up rows.
|
||||||
|
- Heading: `<h3>` to sit below the page H1 in document order.
|
||||||
|
|
||||||
|
#### Block styles (`views/blocks/services-list/services-list.css`)
|
||||||
|
|
||||||
|
```
|
||||||
|
.services-list {
|
||||||
|
/* vertical position of the bisecting line, relative to the .container block.
|
||||||
|
The image is the first child of each card; bisecting it = approx 25% from the top. */
|
||||||
|
--services-line-top: 25%;
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: var(--services-line-top);
|
||||||
|
height: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
/* background SVG served from theme root */
|
||||||
|
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2; /* cards stack above the line so images are not visually cut */
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__heading {
|
||||||
|
@apply text-cwc-blue-01 text-24px font-bold leading-none mt-6 mb-4;
|
||||||
|
/* the orange accent underline from .post-title h1 */
|
||||||
|
&::after {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
@apply text-gray-600 text-16px font-light leading-snug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bisecting line is rendered behind the cards (z-index: 1 on the line, 2 on the grid). The 25% top offset aims at the vertical center of the square image; minor visual variation is acceptable. The line itself is a thin orange `<rect>` SVG (8px tall) spanning full width.
|
||||||
|
|
||||||
|
#### Block registration (`views/blocks/services-list/block.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "acf/services-list",
|
||||||
|
"title": "Services List",
|
||||||
|
"description": "A row of service cards (2, 3, or 4). Layout adapts to the count.",
|
||||||
|
"category": "common",
|
||||||
|
"icon": "grid-view",
|
||||||
|
"keywords": ["services", "cards", "mapping"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "services-list.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The block is auto-registered via `regACFBlocks()` in `functions.php`, which scans `views/blocks/*` and calls `register_block_type()` on each folder.
|
||||||
|
|
||||||
|
### 2. Decorative SVG: `static/img/services-list-line.svg`
|
||||||
|
|
||||||
|
A simple orange horizontal line, 1600×8 viewBox, full-bleed:
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 8" preserveAspectRatio="none">
|
||||||
|
<rect x="0" y="3" width="1600" height="2" fill="#F26B53" />
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `preserveAspectRatio="none"` lets the line stretch to whatever width the container is. Fill `#F26B53` matches the existing brand orange already used in the icons (`stroke="#F26B53"` on `next.php` / `prev.php`).
|
||||||
|
|
||||||
|
### 3. Closing block: reuse `pull-quote`
|
||||||
|
|
||||||
|
The closing "At CWC, mapping is about more than data…" block already matches the `pull-quote` block's contract. The editor adds a `Pull Quote` block to the page, sets:
|
||||||
|
- **Text** (wysiwyg with bold): `At CWC, mapping is about more than data. It is about helping people understand and make decisions about their land and communities.` with **"more than data."** bolded.
|
||||||
|
- **Background Color**: `#E6F0F5` (the light blue in the mockup) or the default `--color-pullquote-bg` if it's already correct.
|
||||||
|
- **Background Image**: optional, if the decorative linework is editor-managed; otherwise leave blank — the existing `mobile-pullquote-vector.svg` / `pull-quote__vector--mobile` provides the linework.
|
||||||
|
|
||||||
|
If `pull-quote`'s default `bg-(--color-pullquote-bg)` already matches the mockup's light blue, no field tweak is needed. If it does not match, the editor sets the explicit hex. **No code change to the pull-quote block.**
|
||||||
|
|
||||||
|
### 4. Page integration
|
||||||
|
|
||||||
|
The mapping page (and any future service sub-page) is composed entirely in Gutenberg:
|
||||||
|
|
||||||
|
1. Add a `Services List` block, populate with 4 items: Community Mapping Initiatives, Spatial Analysis and GIS Services, Spatial Database Management, Data Collection Services, Cartography, Land Related Support Services. Wait — the mockup has 2 large + 4 small = 6 items across 2 separate rows. **Resolution:** the editor adds **two `Services List` blocks** on the mapping page: one with 2 items, one with 4 items. Each block renders as a single row.
|
||||||
|
2. Add a `Pull Quote` block, populate with the closing text.
|
||||||
|
|
||||||
|
`page.php` already iterates `the_content()` and renders whatever blocks are present. No PHP change.
|
||||||
|
|
||||||
|
### 5. CSS import (`styles/blocks/index.css`)
|
||||||
|
|
||||||
|
Add at the end of the existing imports:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Tests (`tests/services-page.spec.js`)
|
||||||
|
|
||||||
|
A new Playwright spec covering the rendered block on the mapping page:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Services List block on /services/mapping/", () => {
|
||||||
|
test("renders the heading and image for each card", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
|
||||||
|
// Both services-list blocks render.
|
||||||
|
const blocks = page.locator(".services-list");
|
||||||
|
await expect(blocks).toHaveCount(2);
|
||||||
|
|
||||||
|
// First block (featured) has 2 cards; second (grid) has 4.
|
||||||
|
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
|
||||||
|
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
|
||||||
|
|
||||||
|
// Each card has an image and a heading.
|
||||||
|
const firstCard = blocks.nth(0).locator(".services-list__card").first();
|
||||||
|
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bisecting line is present and aria-hidden", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const line = page.locator(".services-list__line").first();
|
||||||
|
await expect(line).toBeAttached();
|
||||||
|
await expect(line).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the block", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".services-list")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- `header.php`, `footer.php`, the page hero — explicitly untouched.
|
||||||
|
- A new template file for service pages — `page.php` already suffices.
|
||||||
|
- Per-item "size" override (the size is fully driven by item count).
|
||||||
|
- A custom layout for the closing block — `pull-quote` covers it.
|
||||||
|
- An editor-uploadable linework SVG — the line is a fixed asset.
|
||||||
|
- Mobile-specific layout — the responsive `grid-cols-1 lg:grid-cols-N` handles it. The bisecting line is hidden on mobile (below `lg:`) so it does not bisect the stacked single-column cards awkwardly.
|
||||||
|
- Animations on the cards.
|
||||||
|
- Dark mode.
|
||||||
|
- A new CSS color or a new font — everything reuses existing tokens.
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** One new ACF block (4 files), one SVG asset, two file modifications (CSS index, dist rebuild), one test file. Every spec section is covered.
|
||||||
|
- **Placeholder scan:** No "TBD", "TODO", or vague requirements. The bisecting line CSS, the dynamic `lg:grid-cols-N` class, the card aspect ratio, the title accent reuse — all explicit.
|
||||||
|
- **Type consistency:** Class names follow the existing BEM convention (`services-list__card`, `__media`, `__heading`, `__desc`, `__line`).
|
||||||
|
- **Scope check:** Single focused build, no decomposition needed. Block is small (≤4 files), testable in isolation, and composable on any service page.
|
||||||
|
- **Ambiguity check:** The "2 rows on the mapping page" question is resolved — editor adds 2 instances of the same block, one with 2 items, one with 4. The "orange line bisects images" question is resolved — fixed-positioned SVG with `top: 25%`, behind the cards, hidden on mobile.
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Team Grid Block Design
|
||||||
|
|
||||||
|
> **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 a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
|
||||||
|
|
||||||
|
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Tabs for PHP indentation (project standard).
|
||||||
|
- `static/dist/theme.css` is committed (not gitignored) per project convention.
|
||||||
|
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
|
||||||
|
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
|
||||||
|
- Reuse existing design tokens where possible (e.g. the `text-cwc-blue-01` heading color, `var(--color-secondary)` accent if needed).
|
||||||
|
- The headshot uses `rounded-md` to match other image cards in the project (`post-list`, `services-list`).
|
||||||
|
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
|
||||||
|
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
|
||||||
|
- `page.php` is not modified.
|
||||||
|
- No JavaScript files are added. The `<details>`/`<summary>` interaction is fully native.
|
||||||
|
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
|
||||||
|
|
||||||
|
## Reference Mockup
|
||||||
|
|
||||||
|
`team-example.png` shows a dark-blue section with 8 team members in a 2-column grid. Each member has a circular headshot, name (bold), title (italic), and a multi-line bio below. All bios are visible in the mockup.
|
||||||
|
|
||||||
|
**Intent of the design (per the user):**
|
||||||
|
- The bio should be a `<details>` element that **opens vertically** when the photo, name, or title is clicked. The mockup shows the open state for every member for context; the live behavior is collapsed by default.
|
||||||
|
- The headshot should be a **rectangle with rounded corners that match other images in the project** — `rounded-md` is the project standard. The mockup's circular headshots are a visual approximation; the live design uses rounded rectangles.
|
||||||
|
- The block has no built-in background. The editor wraps it in a Section block (`acf/section`) when a dark or light background is needed.
|
||||||
|
- Layout is responsive: 1 column on mobile, 2 on tablet, 3 on desktop (the user's "3-2-1" choice).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Created/Modified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
|
||||||
|
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
|
||||||
|
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
|
||||||
|
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
|
||||||
|
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
|
||||||
|
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
|
||||||
|
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Details
|
||||||
|
|
||||||
|
### 1. ACF field group: `acf/team-grid`
|
||||||
|
|
||||||
|
**Block name (registration key):** `acf/team-grid`
|
||||||
|
**Editor title:** "Team Grid"
|
||||||
|
**Category:** common
|
||||||
|
**Description (in block.json):** "A responsive grid of team members. Click a card to expand the bio."
|
||||||
|
|
||||||
|
#### ACF field group (`acf/group_team_grid.json`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Group key: group_team_grid
|
||||||
|
Group title: Team Grid
|
||||||
|
Position: normal
|
||||||
|
Menu order: 0
|
||||||
|
Location: block == acf/team-grid
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `members` (repeater, layout: block, button label: "Add Team Member")
|
||||||
|
- `image` (image, return_format: array, required: 0) — Headshot.
|
||||||
|
- `name` (text, required: 0) — Person's name (e.g. "Christopher Werner").
|
||||||
|
- `title` (text, required: 0) — Person's role (e.g. "Principal Consultant & CEO").
|
||||||
|
- `bio` (wysiwyg, tabs: visual, toolbar: basic, media_upload: 0, required: 0) — Bio body copy.
|
||||||
|
|
||||||
|
The block is intentionally minimal. No per-card size, no global layout toggle, no CTA field, no group-level background color. Layout derives from CSS (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`).
|
||||||
|
|
||||||
|
#### Block registration (`views/blocks/team-grid/block.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "acf/team-grid",
|
||||||
|
"title": "Team Grid",
|
||||||
|
"description": "A responsive grid of team members. Click a card to expand the bio.",
|
||||||
|
"category": "common",
|
||||||
|
"icon": "groups",
|
||||||
|
"keywords": ["team", "people", "staff", "bio"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "team-grid.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `icon: "groups"` is a WordPress Dashicon that visually represents a group of people.
|
||||||
|
|
||||||
|
#### Block template (`views/blocks/team-grid/team-grid.php`)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Block Name: Team Grid
|
||||||
|
*
|
||||||
|
* A responsive grid of team members. Each card uses a native <details>
|
||||||
|
* element so the bio expands when the photo, name, or title is clicked.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$members = get_field( 'members' );
|
||||||
|
|
||||||
|
if ( empty( $members ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = 'team-grid container my-section mx-auto';
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section <?php echo wp_kses_post( $wrapper ); ?>>
|
||||||
|
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $members as $member ) : ?>
|
||||||
|
<article class="team-grid__card">
|
||||||
|
<details class="team-grid__details">
|
||||||
|
<summary class="team-grid__summary">
|
||||||
|
<?php if ( ! empty( $member['image'] ) ) : ?>
|
||||||
|
<img
|
||||||
|
class="team-grid__photo"
|
||||||
|
src="<?php echo esc_url( $member['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['name'] ) ) : ?>
|
||||||
|
<h3 class="team-grid__name">
|
||||||
|
<?php echo wp_kses_post( $member['name'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['title'] ) ) : ?>
|
||||||
|
<p class="team-grid__title">
|
||||||
|
<?php echo wp_kses_post( $member['title'] ); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="team-grid__bio-wrap">
|
||||||
|
<div class="team-grid__bio-inner">
|
||||||
|
<?php if ( ! empty( $member['bio'] ) ) : ?>
|
||||||
|
<div class="team-grid__bio">
|
||||||
|
<?php echo wp_kses_post( $member['bio'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Native `<details>`/`<summary>`: the summary is the click target (photo + name + title); the body is the bio wrap.
|
||||||
|
- The `<details>` element is closed by default — no `open` attribute. The user opens it on click.
|
||||||
|
- The bio uses `wysiwyg` so editors can add links or basic formatting. Output is sanitized via `wp_kses_post`.
|
||||||
|
- `team-grid__bio-wrap` is a 1-row grid container that animates `grid-template-rows` from `0fr` to `1fr`. The `__bio-inner` inside has `overflow: hidden; min-height: 0` so the bio is clipped during the transition.
|
||||||
|
|
||||||
|
#### Block styles (`views/blocks/team-grid/team-grid.css`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Team Grid block */
|
||||||
|
|
||||||
|
.team-grid {
|
||||||
|
&__card {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* summary is the click target. Strip the default disclosure triangle. */
|
||||||
|
&__summary {
|
||||||
|
@apply flex flex-col items-center text-center cursor-pointer list-none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::marker {
|
||||||
|
display: none;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__photo {
|
||||||
|
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
@apply text-gray-600 text-16px font-light italic mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation: animate grid-template-rows 0fr -> 1fr. */
|
||||||
|
&__bio-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details[open] &__bio-wrap {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
@apply text-gray-700 text-16px font-light leading-snug pt-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The summary is a flex column with `items-center` so the photo, name, and title stack centered. This matches the mockup.
|
||||||
|
- The default `<summary>` marker (▶ triangle) is hidden with `list-style: none` plus vendor-prefixed `::-webkit-details-marker` and `::marker` reset.
|
||||||
|
- The `&__details[open] &__bio-wrap` selector uses CSS nesting to target the bio wrap when the parent details is open. This compiles to `.team-grid__details[open] .team-grid__bio-wrap { grid-template-rows: 1fr; }`.
|
||||||
|
- The `team-grid__details[open] &__bio-wrap` syntax uses CSS nesting: `&` inside `&__details[open]` is the previous compound selector. Tailwind v4 (lightningcss) unwraps this correctly.
|
||||||
|
- Photo sizes: `w-32 h-32` (8rem / 128px) on mobile, `w-40 h-40` (10rem / 160px) on small, `w-48 h-48` (12rem / 192px) on large.
|
||||||
|
|
||||||
|
### 2. CSS import wiring (`styles/blocks/index.css`)
|
||||||
|
|
||||||
|
Append at the end of `styles/blocks/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import '../../views/blocks/team-grid/team-grid.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
After the edit, the file ends with:
|
||||||
|
|
||||||
|
```
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
@import '../../views/blocks/team-grid/team-grid.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Page integration
|
||||||
|
|
||||||
|
`page.php` is unchanged. The editor adds a `Team Grid` block to the Team page (`/team/`) in Gutenberg. To get the dark-blue background, the editor wraps the `Team Grid` block in a `Section` block (`acf/section`) and sets the Section's `background_color` to the dark blue.
|
||||||
|
|
||||||
|
No PHP template change is required.
|
||||||
|
|
||||||
|
### 4. CSS Build
|
||||||
|
|
||||||
|
Run `npm run build` to compile Tailwind v4 → `static/dist/theme.css`. The new utilities (e.g. `text-22px`, `text-16px`, `w-32 h-32`, `rounded-md`) must appear in the dist.
|
||||||
|
|
||||||
|
### 5. Tests (`tests/team-page.spec.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Team Grid block on /team/", () => {
|
||||||
|
test("renders the photo, name, and title for each member", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const cards = page.locator(".team-grid__card");
|
||||||
|
await expect(cards.first()).toBeVisible();
|
||||||
|
|
||||||
|
const firstCard = cards.first();
|
||||||
|
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bio is hidden by default and expands on summary click", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const firstDetails = page.locator(".team-grid__details").first();
|
||||||
|
// Bio is closed by default.
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
|
||||||
|
// Click the summary; the details should open and the bio should be visible.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).toHaveAttribute("open", "");
|
||||||
|
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
|
||||||
|
|
||||||
|
// Click again; the details should close.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the team-grid block", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".team-grid")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- The Team page template — `page.php` is unchanged. The editor adds the block in Gutenberg.
|
||||||
|
- The Section block wrapper for the dark-blue background — the editor composes this in Gutenberg.
|
||||||
|
- A custom open/close icon or arrow indicator — the click target is the entire summary (photo + name + title), so no chevron is needed.
|
||||||
|
- Animations beyond the bio's expand/collapse transition. No hover transforms, no entry animations.
|
||||||
|
- Per-card size variants — all cards are the same size.
|
||||||
|
- A `data-team-member-id` slug for deep-linking to a specific member's expanded bio.
|
||||||
|
- Dark-mode overrides — the block is color-agnostic and inherits the parent Section's color scheme.
|
||||||
|
- Per-page header/footer changes.
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** One new ACF block (4 files), one CSS import, dist rebuild, one test file. Every spec section is covered.
|
||||||
|
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output.
|
||||||
|
- **Type consistency:** Class names use the existing BEM convention (`__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`). Field names (`members`, `image`, `name`, `title`, `bio`) are used in the JSON, the PHP, and the tests.
|
||||||
|
- **Scope check:** Single focused build, no decomposition needed. Block is small (≤4 files), testable in isolation, and composable on the Team page (and any other page that needs a team grid).
|
||||||
|
- **Ambiguity check:** The "rounded rectangle" headshot is concretely specified as `rounded-md`. The "open vertically" animation is concretely specified as `grid-template-rows: 0fr → 1fr` with a 300ms transition. The "3-2-1" responsive grid is concretely specified. The "no background" decision is concretely scoped to the Section block wrapper (editor's responsibility, not this block's).
|
||||||
+5
-1
@@ -20,7 +20,11 @@ $footerNav = ! empty( $locations['footer_navigation'] )
|
|||||||
? ( wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) ? wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) : array() )
|
? ( wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) ? wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) : array() )
|
||||||
: array();
|
: array();
|
||||||
|
|
||||||
if ( ! is_page( 'contact' ) ) {
|
$services = get_page_by_path( 'services' )->ID;
|
||||||
|
|
||||||
|
$svcChild = $post->post_parent === $services ? true : false;
|
||||||
|
|
||||||
|
if ( ! is_page( 'contact' ) && ! $svcChild ) {
|
||||||
$marginClass = 'mt-20';
|
$marginClass = 'mt-20';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ $showHero = $post->post_parent === $services ? true : false;
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
|
||||||
<?php wp_head(); ?>
|
<?php wp_head(); ?>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ if ( hasSidebar() ) {
|
|||||||
$classes = 'container my-section lg:my-0 mx-auto';
|
$classes = 'container my-section lg:my-0 mx-auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
get_template_part( 'views/icons/prev' );
|
||||||
|
$pagination_prev_icon = ob_get_clean();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
get_template_part( 'views/icons/next' );
|
||||||
|
$pagination_next_icon = ob_get_clean();
|
||||||
|
|
||||||
get_header();
|
get_header();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -23,13 +31,13 @@ get_header();
|
|||||||
<?php if ( have_posts() ) : ?>
|
<?php if ( have_posts() ) : ?>
|
||||||
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
|
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
|
||||||
<div class="post-list">
|
<div class="post-list">
|
||||||
<div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6">
|
<div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-12">
|
||||||
<?php
|
<?php
|
||||||
while ( have_posts() ) :
|
while ( have_posts() ) :
|
||||||
the_post();
|
the_post();
|
||||||
?>
|
?>
|
||||||
<div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500">
|
<div class="post-list__post flex flex-col rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500">
|
||||||
<figure class="post-list__img aspect-video border-b border-secondary rounded-t-md block h-auto w-full overflow-hidden m-0 p-0">
|
<figure class="post-list__img aspect-video rounded-t-md block h-auto w-full overflow-hidden m-0! p-0!">
|
||||||
<?php if ( has_post_thumbnail() ) : ?>
|
<?php if ( has_post_thumbnail() ) : ?>
|
||||||
<?php
|
<?php
|
||||||
$featImg = get_the_post_thumbnail_url();
|
$featImg = get_the_post_thumbnail_url();
|
||||||
@@ -41,17 +49,19 @@ get_header();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="post-list__details px-4 py-6 flex flex-col grow">
|
<div class="post-list__details p-8 pb-10 flex flex-col justify-between grow">
|
||||||
<a href="<?php the_permalink(); ?>" class="">
|
<h2 class="post-list__title font-normal text-32px mt-0 mx-0">
|
||||||
<h2 class="post-list__title font-normal text-25px text-balance line-clamp-4 truncate mt-0 mb-5 mx-0"><?php the_title(); ?></h2>
|
<a href="<?php the_permalink(); ?>" class=""><?php the_title(); ?></a>
|
||||||
</a>
|
</h2>
|
||||||
|
|
||||||
<div class="post-list__byline mt-auto">
|
<div class="post-list__excerpt line-clamp-5 mb-4">
|
||||||
<span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> —</span>
|
<?php the_excerpt(); ?>
|
||||||
<span class="post-list__date"><?php echo get_the_date(); ?></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="post-list__read-more" href="<?php the_permalink(); ?>">Read more →</a>
|
<?php /* translators: %s: Post title. */ ?>
|
||||||
|
<a class="post-list__read-more border-b-2 border-b-secondary w-fit" href="<?php echo esc_url( $post_permalink ); ?>" aria-label="<?php echo esc_attr( sprintf( __( 'Continue reading %s', 'cwc' ), $post_title ) ); ?>">
|
||||||
|
<?php echo esc_html__( 'Read More', 'cwc' ); ?>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endwhile; ?>
|
<?php endwhile; ?>
|
||||||
@@ -62,8 +72,8 @@ get_header();
|
|||||||
the_posts_pagination(
|
the_posts_pagination(
|
||||||
array(
|
array(
|
||||||
'mid_size' => 2,
|
'mid_size' => 2,
|
||||||
'prev_text' => '« Previous',
|
'prev_text' => $pagination_prev_icon . '<span class="sr-only">' . esc_html__( 'Previous', 'cwc' ) . '</span>',
|
||||||
'next_text' => 'Next »',
|
'next_text' => $pagination_next_icon . '<span class="sr-only">' . esc_html__( 'Next', 'cwc' ) . '</span>',
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ add_filter(
|
|||||||
unset( $blocks['core/calendar'] );
|
unset( $blocks['core/calendar'] );
|
||||||
unset( $blocks['core/buttons'] );
|
unset( $blocks['core/buttons'] );
|
||||||
unset( $blocks['core/search'] );
|
unset( $blocks['core/search'] );
|
||||||
|
unset( $blocks['core/pullquote'] );
|
||||||
|
|
||||||
// return the new list of allowed blocks
|
// return the new list of allowed blocks
|
||||||
return array_keys( $blocks );
|
return array_keys( $blocks );
|
||||||
|
|||||||
+16
-38
@@ -27,52 +27,30 @@ if ( hasSidebar() ) {
|
|||||||
the_post();
|
the_post();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="post-title mb-0">
|
<div class="post-img">
|
||||||
<h1 class="mb-0"><?php the_title(); ?></h1>
|
<?php if ( has_post_thumbnail() ) : ?>
|
||||||
|
<img src="<?php the_post_thumbnail_url( 'full' ); ?>" alt="<?php the_title(); ?>" class="w-full h-auto mb-6">
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-meta text-14px italic mb-6">
|
<div class="post-title mb-0">
|
||||||
|
<h1 class=""><?php the_title(); ?></h1>
|
||||||
|
|
||||||
|
<div class="post-subtitle text-28px font-quincy font-bold m-0 mb-6 p-0 leading-none text-cwc-blue-02">
|
||||||
|
<?php the_field( 'subtitle' ); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-meta text-14px text-cwc-orange-01 uppercase font-bold mb-6">
|
||||||
<div class="">
|
<div class="">
|
||||||
<span class="post-date">Posted: <?php the_date(); ?></span> | <span class="post-author">Posted by: <?php echo esc_html( ucfirst( get_the_author() ) ); ?></span>
|
<span class="post-date">Posted: <?php the_date(); ?></span> | <span class="post-author">Posted by: <?php echo esc_html( ucfirst( get_the_author() ) ); ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<span class="post-categories">
|
|
||||||
<?php
|
|
||||||
$categories = get_the_category();
|
|
||||||
if ( ! empty( $categories ) ) {
|
|
||||||
echo '<span>Posted in: ';
|
|
||||||
foreach ( $categories as $ct ) {
|
|
||||||
echo '<a href="' . esc_url( get_category_link( $ct->cat_ID ) ) . '">' . esc_html( $ct->name ) . '</a>';
|
|
||||||
if ( count( $categories ) > 1 && $ct !== end( $categories ) ) {
|
|
||||||
echo ', ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo '</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
|
|
|
||||||
<span class="post-tags">
|
|
||||||
<?php
|
|
||||||
$tags = get_the_tags();
|
|
||||||
if ( ! empty( $tags ) ) {
|
|
||||||
echo '<span>Tagged: ';
|
|
||||||
foreach ( $tags as $tg ) {
|
|
||||||
echo '<a href="' . esc_url( get_tag_link( $tg->term_id ) ) . '">' . esc_html( $tg->name ) . '</a>';
|
|
||||||
if ( count( $tags ) > 1 && $tg !== end( $tags ) ) {
|
|
||||||
echo ', ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo '</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content font-light">
|
||||||
|
<?php the_content(); ?>
|
||||||
|
</div>
|
||||||
<?php
|
<?php
|
||||||
the_content();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
Vendored
+6043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 32" preserveAspectRatio="none">
|
||||||
|
<rect x="0" y="3" width="1600" height="32" fill="#F26B53" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 162 B |
@@ -1,2 +1,3 @@
|
|||||||
/* Light admin styles; avoid overriding core wp-admin UI */
|
/* Light admin styles; avoid overriding core wp-admin UI */
|
||||||
|
|
||||||
|
#adminmenu ol, ul, li ul, ol li { margin: 0; }
|
||||||
|
|||||||
@@ -3,3 +3,16 @@
|
|||||||
.social-icons svg {
|
.social-icons svg {
|
||||||
@apply bg-transparent fill-white rounded-full block;
|
@apply bg-transparent fill-white rounded-full block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-title h1 {
|
||||||
|
@apply mb-8 text-cwc-blue-01 leading-none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
@import './misc.css';
|
@import './misc.css';
|
||||||
@import './page-hero.css';
|
@import './page-hero.css';
|
||||||
@import '../../views/blocks/contact-info/contact-info.css';
|
@import '../../views/blocks/contact-info/contact-info.css';
|
||||||
|
@import '../../views/blocks/services-list/services-list.css';
|
||||||
|
@import '../../views/blocks/team-grid/team-grid.css';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@apply text-cwc-blue-01;
|
@apply text-cwc-blue-01;
|
||||||
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
em {
|
em {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.page-hero__intro {
|
.page-hero__intro {
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1.3;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
background: linear-gradient(73.83deg, #8FC9E6 -8.7%, #006196 21.51%, #032F46 60.7%);
|
background: linear-gradient(73.83deg, #8FC9E6 -8.7%, #006196 21.51%, #032F46 60.7%);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
margin-bottom: 10rem;
|
margin-bottom: 10rem;
|
||||||
min-height: clamp(31rem, 38vw, 36rem);
|
min-height: clamp(31rem, 38.5417vw, 46.25rem);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,21 +48,22 @@
|
|||||||
|
|
||||||
.page-hero-services__intro {
|
.page-hero-services__intro {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: clamp(1rem, 1.5vw, 1.25rem);
|
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
max-width: 36rem;
|
max-width: 32rem;
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero-services__media {
|
.page-hero-services__media {
|
||||||
|
@apply rounded-xl;
|
||||||
bottom: -5%;
|
bottom: -5%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 23vw;
|
right: 28vw;
|
||||||
top: 3rem;
|
top: 3rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -71,7 +72,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-hero-services__vector {
|
.page-hero-services__vector {
|
||||||
bottom: clamp(1.5rem, 4vw, 3rem);
|
/* bottom: clamp(1.5rem, 4vw, 3rem); */
|
||||||
|
bottom: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -5,31 +5,25 @@
|
|||||||
@apply hidden md:flex items-center justify-center mt-12;
|
@apply hidden md:flex items-center justify-center mt-12;
|
||||||
|
|
||||||
.page-numbers {
|
.page-numbers {
|
||||||
@apply hidden md:flex items-center justify-center h-10 p-4 text-base font-medium transition duration-300 rounded stroke-primary text-primary hover:bg-info hover:text-light hover:stroke-info focus-visible:bg-info focus-visible:text-light focus-visible:stroke-info focus-visible:outline-none;
|
@apply hidden md:flex items-center justify-center h-16 w-16 text-32px font-quincy font-medium transition duration-300 rounded stroke-primary text-primary hover:bg-info hover:text-light hover:stroke-info focus-visible:bg-info focus-visible:text-light focus-visible:stroke-info focus-visible:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-numbers.current {
|
.page-numbers.current { @apply text-light whitespace-nowrap bg-info ring-offset-2 hover:bg-primary hover:stroke-primary focus-visible:bg-info; }
|
||||||
@apply text-light whitespace-nowrap bg-info ring-offset-2 hover:bg-primary hover:stroke-primary focus-visible:bg-info;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev, .next {
|
.prev, .next { @apply flex gap-4 w-fit; }
|
||||||
@apply flex gap-4;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& a, & span {
|
& a, & span {
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #3857BC !important;
|
color: var(--color-cwc-blue-01) !important;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
padding: .25rem .5rem;
|
padding: .25rem .5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .current, & a:hover {
|
& .current, & a:hover {
|
||||||
background: #3857BC !important;
|
background: color-mix(in oklch, var(--color-cwc-blue-03), white 50%) !important;
|
||||||
border-color: #3857BC !important;
|
color: var(--color-cwc-blue-01) !important;
|
||||||
color: #fff !important;
|
|
||||||
padding: .25rem .5rem;
|
padding: .25rem .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
/* Blog/post index listing styles */
|
/* Blog/post index listing styles */
|
||||||
|
|
||||||
.post-list__h1 {
|
.post-list__post { overflow: hidden; }
|
||||||
color: var(--color-cwc-blue-01);
|
|
||||||
font-family: var(--font-quincy, 'Quincy', serif);
|
|
||||||
font-size: var(--h1);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0 0 2rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list__post {
|
.post-list__img { border-bottom: 0; }
|
||||||
border-color: var(--color-cwc-blue-03);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list__img {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list__details {
|
.post-list__details {
|
||||||
background: var(--color-cwc-blue-03);
|
background: color-mix(in oklch, var(--color-cwc-blue-03) 60%, white);
|
||||||
color: var(--color-cwc-blue-01);
|
color: var(--color-cwc-blue-01);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list__title {
|
.post-list__title {
|
||||||
color: var(--color-cwc-blue-01);
|
color: var(--color-cwc-blue-01);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-cwc-blue-01);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list__excerpt {
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-size: var(--text-18px);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list__byline {
|
.post-list__byline {
|
||||||
@@ -42,5 +49,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-list__read-more:hover {
|
.post-list__read-more:hover {
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-13
@@ -1,28 +1,162 @@
|
|||||||
/* Sidebar styles */
|
/* Sidebar styles */
|
||||||
|
|
||||||
.sidebar{
|
.sidebar{
|
||||||
.widget {
|
@apply pt-1;
|
||||||
/* Widget styles */
|
|
||||||
@apply mb-8;
|
|
||||||
|
|
||||||
h3 {
|
.widget {
|
||||||
/* Widget title styles */
|
@apply mb-10;
|
||||||
@apply m-0 mb-2 font-semibold;
|
|
||||||
|
&:last-child { @apply mb-0; }
|
||||||
|
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.wp-block-heading {
|
||||||
|
@apply m-0 mb-4 pb-0;
|
||||||
|
|
||||||
|
color: var(--color-cwc-blue-01);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-14px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
/* List styles */
|
@apply list-none m-0 p-0;
|
||||||
@apply list-none m-0 p-0 pl-3 border-l-3 border-secondary-400;
|
|
||||||
|
|
||||||
li {
|
li {
|
||||||
/* List item styles */
|
@apply m-0 leading-none!;
|
||||||
@apply text-balance;
|
|
||||||
|
|
||||||
&:first-of-type {
|
&:not(:last-child) { @apply mb-4; }
|
||||||
/* First list item styles */
|
|
||||||
@apply leading-none mb-1;
|
a {
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-cwc-blue-01); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
@apply px-5 py-3 rounded-sm border-2 border-cwc-blue-03;
|
||||||
|
|
||||||
|
background-color: color-mix(in oklch, var(--color-cwc-blue-03) 40%, white);
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='7' viewBox='0 0 12 7' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.25L6 5.75L11 1.25' stroke='%23F26B53' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 0.75rem auto;
|
||||||
|
padding-right: 2.75rem;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: var(--color-cwc-blue-01);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-search__label,
|
||||||
|
.screen-reader-text {
|
||||||
|
@apply absolute left-5 top-1/2 pointer-events-none;
|
||||||
|
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
transform: translateY(1px);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.25 12.25C13.4167 11.0833 14 9.66667 14 8C14 6.33333 13.4167 4.91667 12.25 3.75C11.0833 2.58333 9.66667 2 8 2C6.33333 2 4.91667 2.58333 3.75 3.75C2.58333 4.91667 2 6.33333 2 8C2 9.66667 2.58333 11.0833 3.75 12.25C4.91667 13.4167 6.33333 14 8 14C9.66667 14 11.0833 13.4167 12.25 12.25ZM8 16C5.76667 16 3.875 15.225 2.325 13.675C0.775 12.125 0 10.2333 0 8C0 5.76667 0.775 3.875 2.325 2.325C3.875 0.775 5.76667 0 8 0C10.2333 0 12.125 0.775 13.675 2.325C15.225 3.875 16 5.76667 16 8C16 8.93333 15.8542 9.8125 15.5625 10.6375C15.2708 11.4625 14.8583 12.2167 14.325 12.9L20 18.6L18.6 20L12.9 14.325C12.2167 14.8583 11.4625 15.2708 10.6375 15.5625C9.8125 15.8542 8.93333 16 8 16Z' fill='%23F26B53'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-search {
|
||||||
|
@apply relative;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
.wp-block-search__label { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-search__inside-wrapper { @apply relative; }
|
||||||
|
|
||||||
|
.wp-block-search__input {
|
||||||
|
@apply rounded-sm border-2 border-cwc-blue-03 px-5 py-3;
|
||||||
|
|
||||||
|
background-color: color-mix(in oklch, var(--color-cwc-blue-03) 40%, white);
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding-left: 8.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-search__button { @apply hidden; }
|
||||||
|
|
||||||
|
&:has(.wp-block-search__input:not(:placeholder-shown)) { .wp-block-search__label { opacity: 0; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.widget_search {
|
||||||
|
@apply mb-9;
|
||||||
|
|
||||||
|
.wp-block-search__input {
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: var(--color-cwc-blue-01);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(ul) {
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.wp-block-heading {
|
||||||
|
@apply mb-2;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply block mt-2;
|
||||||
|
|
||||||
|
content: "";
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-archives__label {
|
||||||
|
@apply sr-only;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.widget {
|
||||||
|
ul a { font-size: var(--text-16px); }
|
||||||
|
|
||||||
|
.wp-block-search__label,
|
||||||
|
.screen-reader-text {
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-block-search .wp-block-search__input {
|
||||||
|
font-size: var(--text-16px);
|
||||||
|
padding-left: 7.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media screen and (max-width: 62.5rem) {
|
@media screen and (max-width: 79.9375rem) {
|
||||||
#app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); }
|
#app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); }
|
||||||
|
|
||||||
.nav-main__toggle {
|
.nav-main__toggle {
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop */
|
/* Desktop */
|
||||||
@media screen and (min-width: 62.5rem) {
|
@media screen and (min-width: 80rem) {
|
||||||
.nav-main__toggle {
|
.nav-main__toggle {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* desktop */
|
/* desktop */
|
||||||
@media screen and (min-width: 62.5rem) {
|
@media screen and (min-width: 80rem) {
|
||||||
.nav-main .menu-vdi {
|
.nav-main .menu-vdi {
|
||||||
@apply flex items-center justify-end p-0 m-0 h-full;
|
@apply flex items-center justify-end p-0 m-0 h-full;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 62.5rem) {
|
@media screen and (min-width: 80rem) {
|
||||||
.menu-vdi {
|
.menu-vdi {
|
||||||
.menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; }
|
.menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; }
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
top: calc(100% + 1rem);
|
top: calc(100% + 1rem);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
|
|
||||||
@apply bg-cwc-blue-02 shadow-lg left-0 w-64 flex-col rounded-b-lg ml-0;
|
@apply bg-cwc-blue-02 shadow-lg left-0 w-84 flex-col rounded-b-lg ml-0;
|
||||||
|
|
||||||
>li {
|
>li {
|
||||||
@apply w-full border-b border-[#D24D32];
|
@apply w-full border-b border-[#D24D32];
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for the four mobile-blog layout bugs:
|
||||||
|
*
|
||||||
|
* 1. Hero text too close to the header.
|
||||||
|
* 2. Gap between title and intro too big (80px instead of ~24px).
|
||||||
|
* 3. Intro runs to the viewport edge on the left.
|
||||||
|
* 4. Post card runs to the viewport edge on the right.
|
||||||
|
*
|
||||||
|
* Tests run against the local Herd site where the rebuilt theme.css is
|
||||||
|
* served. The live deployment (solowebdesigns.net) will pick up the
|
||||||
|
* rebuilt CSS when the user deploys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const blogUrl = "http://community-works-collaborative.test/blog/";
|
||||||
|
|
||||||
|
test.describe("blog page mobile layout", () => {
|
||||||
|
test.use({ viewport: { width: 402, height: 874 } });
|
||||||
|
|
||||||
|
test("hero has top padding so it isn't glued to the header", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const header = document.querySelector(".site-header");
|
||||||
|
const hero = document.querySelector(".page-hero");
|
||||||
|
const heading = document.querySelector(".page-hero__heading");
|
||||||
|
const cs = hero ? getComputedStyle(hero) : null;
|
||||||
|
return {
|
||||||
|
headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : null,
|
||||||
|
heroTop: hero ? Math.round(hero.getBoundingClientRect().top) : null,
|
||||||
|
heroPaddingTop: cs ? cs.paddingTop : null,
|
||||||
|
headingTop: heading ? Math.round(heading.getBoundingClientRect().top) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hero must have some top padding (>= 8px) on mobile.
|
||||||
|
const pt = parseFloat(data.heroPaddingTop) || 0;
|
||||||
|
expect(
|
||||||
|
pt,
|
||||||
|
`hero paddingTop should be >= 8px on mobile, got ${pt}`,
|
||||||
|
).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
|
// The heading shouldn't be touching the bottom of the header.
|
||||||
|
const gap = data.headingTop - data.headerBottom;
|
||||||
|
expect(
|
||||||
|
gap,
|
||||||
|
`heading should sit at least 8px below the header, got ${gap}px`,
|
||||||
|
).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title-to-intro gap is ~24px (not 80px) on mobile", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const gap = await page.evaluate(() => {
|
||||||
|
const heading = document.querySelector(".page-hero__heading");
|
||||||
|
const intro = document.querySelector(".page-hero__intro");
|
||||||
|
if (!heading || !intro) return null;
|
||||||
|
const hRect = heading.getBoundingClientRect();
|
||||||
|
const iRect = intro.getBoundingClientRect();
|
||||||
|
return Math.round(iRect.top - hRect.bottom);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gap, `title-to-intro gap should be ~24px, got ${gap}px`).toBeLessThanOrEqual(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hero intro is padded from the left edge", async ({ page }) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const intro = document.querySelector(".page-hero__intro");
|
||||||
|
const heading = document.querySelector(".page-hero__heading");
|
||||||
|
if (!intro || !heading) return null;
|
||||||
|
return {
|
||||||
|
introX: Math.round(intro.getBoundingClientRect().x),
|
||||||
|
headingX: Math.round(heading.getBoundingClientRect().x),
|
||||||
|
winW: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The intro should have at least 16px of left padding from the viewport edge.
|
||||||
|
expect(
|
||||||
|
data.introX,
|
||||||
|
`intro x=${data.introX} should be >= 16`,
|
||||||
|
).toBeGreaterThanOrEqual(16);
|
||||||
|
expect(
|
||||||
|
data.headingX,
|
||||||
|
`heading x=${data.headingX} should be >= 16`,
|
||||||
|
).toBeGreaterThanOrEqual(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("intro has bottom margin so it doesn't sit on the first card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const intro = document.querySelector(".page-hero__intro");
|
||||||
|
const firstCard = document.querySelector(".post-list__post");
|
||||||
|
if (!intro || !firstCard) return null;
|
||||||
|
const cs = intro ? getComputedStyle(intro) : null;
|
||||||
|
const iRect = intro.getBoundingClientRect();
|
||||||
|
const cRect = firstCard.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
introMarginBottom: cs ? cs.marginBottom : null,
|
||||||
|
introBottom: Math.round(iRect.bottom),
|
||||||
|
cardTop: Math.round(cRect.top),
|
||||||
|
gap: Math.round(cRect.top - iRect.bottom),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The intro must have a non-zero bottom margin.
|
||||||
|
const mb = parseFloat(data.introMarginBottom) || 0;
|
||||||
|
expect(
|
||||||
|
mb,
|
||||||
|
`intro marginBottom should be >= 16px, got ${mb}`,
|
||||||
|
).toBeGreaterThanOrEqual(16);
|
||||||
|
|
||||||
|
// The intro should sit at least 16px above the first card.
|
||||||
|
expect(
|
||||||
|
data.gap,
|
||||||
|
`intro-to-card gap should be >= 16px, got ${data.gap}px`,
|
||||||
|
).toBeGreaterThanOrEqual(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("post card fits within the viewport on mobile", async ({ page }) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const card = document.querySelector(".post-list__post");
|
||||||
|
if (!card) return null;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.round(r.x),
|
||||||
|
w: Math.round(r.width),
|
||||||
|
right: Math.round(r.right),
|
||||||
|
winW: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The card must not overflow the viewport on either side.
|
||||||
|
expect(
|
||||||
|
data.x,
|
||||||
|
`card x=${data.x} should be >= 0`,
|
||||||
|
).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(
|
||||||
|
data.right,
|
||||||
|
`card right=${data.right} should be <= ${data.winW}`,
|
||||||
|
).toBeLessThanOrEqual(data.winW);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no horizontal overflow on the blog page at 402", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => ({
|
||||||
|
docW: document.documentElement.scrollWidth,
|
||||||
|
winW: window.innerWidth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
data.docW,
|
||||||
|
`docW=${data.docW} exceeds winW=${data.winW}`,
|
||||||
|
).toBeLessThanOrEqual(data.winW + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("blog page mobile layout (xs 360)", () => {
|
||||||
|
test.use({ viewport: { width: 360, height: 800 } });
|
||||||
|
|
||||||
|
test("post card fits within 360px viewport", async ({ page }) => {
|
||||||
|
await page.goto(blogUrl, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const card = document.querySelector(".post-list__post");
|
||||||
|
if (!card) return null;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x), w: Math.round(r.width), right: Math.round(r.right), winW: window.innerWidth };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(data.right).toBeLessThanOrEqual(data.winW);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for the 1024-1200px header-nav overflow.
|
||||||
|
*
|
||||||
|
* Bug: at viewport widths between 1000 and ~1200, the inline main-navigation
|
||||||
|
* was wider than the header container, pushing doc.scrollWidth past
|
||||||
|
* window.innerWidth and producing a horizontal scrollbar.
|
||||||
|
*
|
||||||
|
* Fix: the desktop-nav breakpoint moved from 62.5rem (1000px) to 80rem
|
||||||
|
* (1280px), so the hamburger now covers 1000-1279 and the inline nav
|
||||||
|
* appears at 1280+.
|
||||||
|
*
|
||||||
|
* These tests assert:
|
||||||
|
* - no horizontal overflow at any of the previously-broken widths
|
||||||
|
* - the hamburger toggle is visible below 1280
|
||||||
|
* - the inline nav is visible at 1280+
|
||||||
|
*
|
||||||
|
* Tests run against the local Herd site where the rebuilt theme.css is
|
||||||
|
* served. The live deployment (solowebdesigns.net) will pick up the
|
||||||
|
* rebuilt CSS when the user deploys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const site = "http://community-works-collaborative.test/";
|
||||||
|
|
||||||
|
const widths = [1000, 1023, 1024, 1100, 1200, 1279, 1280, 1366, 1440];
|
||||||
|
|
||||||
|
for (const w of widths) {
|
||||||
|
test(`no horizontal overflow at width ${w}`, async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: w, height: 800 },
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const { docW, winW } = await page.evaluate(() => ({
|
||||||
|
docW: document.documentElement.scrollWidth,
|
||||||
|
winW: window.innerWidth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
docW,
|
||||||
|
`viewport ${w}: docW=${docW} exceeds winW=${winW} by ${docW - winW}px`,
|
||||||
|
).toBeLessThanOrEqual(winW + 1);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of [1000, 1023, 1024, 1100, 1200, 1279]) {
|
||||||
|
test(`hamburger visible below 1280 at width ${w}`, async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: w, height: 800 },
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const toggleVisible = await page.evaluate(() => {
|
||||||
|
const t = document.querySelector(".nav-main__toggle");
|
||||||
|
if (!t) return false;
|
||||||
|
const cs = getComputedStyle(t);
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
cs.display !== "none" &&
|
||||||
|
cs.visibility !== "hidden" &&
|
||||||
|
r.width > 0 &&
|
||||||
|
r.height > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toggleVisible,
|
||||||
|
`width ${w}: hamburger should be visible`,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of [1280, 1366, 1440, 1920]) {
|
||||||
|
test(`inline nav visible at 1280+ at width ${w}`, async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: w, height: 800 },
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const toggle = document.querySelector(".nav-main__toggle");
|
||||||
|
const menu = document.querySelector(".nav-main .menu-vdi");
|
||||||
|
const nav = document.querySelector(".nav-main");
|
||||||
|
return {
|
||||||
|
toggleDisplay: toggle ? getComputedStyle(toggle).display : null,
|
||||||
|
menuDisplay: menu ? getComputedStyle(menu).display : null,
|
||||||
|
navRight: nav ? Math.round(nav.getBoundingClientRect().right) : null,
|
||||||
|
docW: document.documentElement.scrollWidth,
|
||||||
|
winW: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hamburger hidden
|
||||||
|
expect(
|
||||||
|
result.toggleDisplay,
|
||||||
|
`width ${w}: hamburger should be hidden at 1280+`,
|
||||||
|
).toBe("none");
|
||||||
|
|
||||||
|
// Inline menu visible (display: flex from the @apply)
|
||||||
|
expect(
|
||||||
|
result.menuDisplay,
|
||||||
|
`width ${w}: inline menu should be visible at 1280+`,
|
||||||
|
).toBe("flex");
|
||||||
|
|
||||||
|
// Nav stays inside the viewport
|
||||||
|
expect(
|
||||||
|
result.docW,
|
||||||
|
`width ${w}: docW=${result.docW} exceeds winW=${result.winW}`,
|
||||||
|
).toBeLessThanOrEqual(result.winW + 1);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const site = "http://community-works-collaborative.test/";
|
||||||
|
|
||||||
|
// Viewports to audit (mobile / tablet / desktop / wide)
|
||||||
|
const viewports = [
|
||||||
|
{ name: "xs-360", width: 360, height: 800 },
|
||||||
|
{ name: "sm-402", width: 402, height: 874 },
|
||||||
|
{ name: "md-768", width: 768, height: 1024 },
|
||||||
|
{ name: "lg-1024", width: 1024, height: 768 },
|
||||||
|
{ name: "xl-1280", width: 1280, height: 800 },
|
||||||
|
{ name: "2xl-1440", width: 1440, height: 900 },
|
||||||
|
{ name: "3xl-1920", width: 1920, height: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Common selectors to audit for overflow / alignment
|
||||||
|
const selectors = {
|
||||||
|
header: ".site-header",
|
||||||
|
logo: ".site-header__logo",
|
||||||
|
navToggle: ".nav-main__toggle",
|
||||||
|
main: "main, .site-main, .page-content",
|
||||||
|
body: "body",
|
||||||
|
hero: ".homepage-hero, .page-hero",
|
||||||
|
cards: ".card, .post-card",
|
||||||
|
sections: "section",
|
||||||
|
footer: ".site-footer, footer",
|
||||||
|
grid: ".grid, [class*='grid']",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for horizontal overflow at every viewport
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`no horizontal overflow at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(() => {
|
||||||
|
const docWidth = document.documentElement.scrollWidth;
|
||||||
|
const winWidth = window.innerWidth;
|
||||||
|
const offenders = [];
|
||||||
|
|
||||||
|
// Find any element wider than the viewport or that pushes the doc to overflow
|
||||||
|
if (docWidth > winWidth + 1) {
|
||||||
|
const all = document.querySelectorAll("*");
|
||||||
|
for (const el of all) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.right > winWidth + 1) {
|
||||||
|
offenders.push({
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
class: el.className?.toString?.().slice(0, 80),
|
||||||
|
right: Math.round(rect.right),
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
});
|
||||||
|
if (offenders.length >= 10) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { docWidth, winWidth, offenders };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
overflow.docWidth,
|
||||||
|
`Doc width ${overflow.docWidth} > viewport ${overflow.winWidth}. Offenders: ${JSON.stringify(overflow.offenders)}`,
|
||||||
|
).toBeLessThanOrEqual(overflow.winWidth + 1);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header alignment per viewport
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`header alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const header = page.locator(selectors.header).first();
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
|
||||||
|
// Header must start at the left edge (allow 1px sub-pixel)
|
||||||
|
expect(Math.abs(box.x)).toBeLessThanOrEqual(1);
|
||||||
|
expect(box.width).toBeGreaterThan(vp.width * 0.8);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content padding/alignment per viewport
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`main content padding at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const main = page.locator(selectors.main).first();
|
||||||
|
const mainCount = await page.locator(selectors.main).count();
|
||||||
|
|
||||||
|
if (mainCount > 0) {
|
||||||
|
const box = await main.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
|
||||||
|
// Check for symmetric horizontal padding (allow 1px)
|
||||||
|
const leftPad = box.x;
|
||||||
|
const rightPad = vp.width - (box.x + box.width);
|
||||||
|
expect(Math.abs(leftPad - rightPad)).toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section spacing audit
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`section vertical rhythm at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
// Gather all top-level sections and report top/bottom paddings
|
||||||
|
const sectionInfo = await page.evaluate(() => {
|
||||||
|
const sections = Array.from(
|
||||||
|
document.querySelectorAll("body > * section, main > section"),
|
||||||
|
);
|
||||||
|
return sections.slice(0, 15).map((s) => {
|
||||||
|
const cs = getComputedStyle(s);
|
||||||
|
const rect = s.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
class: s.className?.toString?.().slice(0, 60),
|
||||||
|
top: Math.round(rect.top),
|
||||||
|
height: Math.round(rect.height),
|
||||||
|
paddingTop: cs.paddingTop,
|
||||||
|
paddingBottom: cs.paddingBottom,
|
||||||
|
marginTop: cs.marginTop,
|
||||||
|
marginBottom: cs.marginBottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// No section should have 0 vertical padding (visual breathing room)
|
||||||
|
for (const s of sectionInfo) {
|
||||||
|
const pt = parseFloat(s.paddingTop) || 0;
|
||||||
|
const pb = parseFloat(s.paddingBottom) || 0;
|
||||||
|
// At least one of top/bottom padding should be > 8px for breathing room
|
||||||
|
// (except narrow edge cases)
|
||||||
|
if (s.height > 50) {
|
||||||
|
expect(
|
||||||
|
Math.max(pt, pb),
|
||||||
|
`Section "${s.class}" has insufficient vertical padding (pt=${pt}, pb=${pb})`,
|
||||||
|
).toBeGreaterThan(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card grid alignment at common breakpoints
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`card alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const cardCount = await page.locator(selectors.cards).count();
|
||||||
|
if (cardCount < 2) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxes = await page
|
||||||
|
.locator(selectors.cards)
|
||||||
|
.evaluateAll((els) =>
|
||||||
|
els
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.round(r.x),
|
||||||
|
y: Math.round(r.y),
|
||||||
|
w: Math.round(r.width),
|
||||||
|
h: Math.round(r.height),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// All visible cards should be within the viewport
|
||||||
|
for (const b of boxes) {
|
||||||
|
expect(b.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(b.x + b.w).toBeLessThanOrEqual(vp.width + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by row (similar y) and verify alignment
|
||||||
|
const rows = {};
|
||||||
|
for (const b of boxes) {
|
||||||
|
const rowKey = Math.round(b.y / 10); // tolerance for sub-row alignment
|
||||||
|
if (!rows[rowKey]) rows[rowKey] = [];
|
||||||
|
rows[rowKey].push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of Object.values(rows)) {
|
||||||
|
if (row.length < 2) continue;
|
||||||
|
// Same-row items should share a common left or right edge
|
||||||
|
// (allow 2px tolerance for grid gap math)
|
||||||
|
const xs = row.map((b) => b.x);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
// All should be left-aligned to the same x OR span the row
|
||||||
|
// We just want to ensure they're not wildly misaligned
|
||||||
|
expect(
|
||||||
|
maxX - minX,
|
||||||
|
`Cards in same row misaligned: ${JSON.stringify(row)}`,
|
||||||
|
).toBeLessThanOrEqual(vp.width * 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer alignment
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`footer alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const footer = page.locator(selectors.footer).first();
|
||||||
|
const count = await page.locator(selectors.footer).count();
|
||||||
|
if (count === 0) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = await footer.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
// Footer should not overflow horizontally
|
||||||
|
expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image / media overflow
|
||||||
|
for (const vp of viewports) {
|
||||||
|
test(`images fit viewport at ${vp.name} (${vp.width}x${vp.height})`, async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const overflows = await page.evaluate(() => {
|
||||||
|
const imgs = Array.from(document.querySelectorAll("img"));
|
||||||
|
const out = [];
|
||||||
|
for (const img of imgs) {
|
||||||
|
const r = img.getBoundingClientRect();
|
||||||
|
if (r.right > window.innerWidth + 1 || r.width > window.innerWidth + 1) {
|
||||||
|
out.push({
|
||||||
|
src: img.src.slice(-60),
|
||||||
|
width: Math.round(r.width),
|
||||||
|
right: Math.round(r.right),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(overflows.length, `Images overflow: ${JSON.stringify(overflows)}`).toBe(0);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Services List block on /services/mapping/", () => {
|
||||||
|
test("renders the heading and image for each card", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
|
||||||
|
const blocks = page.locator(".services-list");
|
||||||
|
await expect(blocks).toHaveCount(2);
|
||||||
|
|
||||||
|
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
|
||||||
|
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
|
||||||
|
|
||||||
|
await expect(blocks.nth(0)).toHaveClass(/lg:grid-cols-2/);
|
||||||
|
await expect(blocks.nth(1)).toHaveClass(/lg:grid-cols-4/);
|
||||||
|
|
||||||
|
const firstCard = blocks.nth(0).locator(".services-list__card").first();
|
||||||
|
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bisecting line is present and aria-hidden", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const line = page.locator(".services-list__line").first();
|
||||||
|
await expect(line).toBeAttached();
|
||||||
|
await expect(line).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the services-list block", async ({ page }) => {
|
||||||
|
await page.goto("/services/mapping/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".services-list")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
test.describe("Team Grid block on /team/", () => {
|
||||||
|
test("renders the photo, name, and title for each member", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const cards = page.locator(".team-grid__card");
|
||||||
|
await expect(cards.first()).toBeVisible();
|
||||||
|
|
||||||
|
const firstCard = cards.first();
|
||||||
|
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
|
||||||
|
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bio is hidden by default and expands on summary click", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
|
||||||
|
const firstDetails = page.locator(".team-grid__details").first();
|
||||||
|
// Bio is closed by default.
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
|
||||||
|
// Click the summary; the details should open and the bio should be visible.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).toHaveAttribute("open", "");
|
||||||
|
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
|
||||||
|
|
||||||
|
// Click again; the details should close.
|
||||||
|
await firstDetails.locator(".team-grid__summary").click();
|
||||||
|
await expect(firstDetails).not.toHaveAttribute("open", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no axe violations on the team-grid block", async ({ page }) => {
|
||||||
|
await page.goto("/team/");
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.include(".team-grid")
|
||||||
|
.analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,7 +34,6 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-wrap: balance;
|
|
||||||
|
|
||||||
strong,
|
strong,
|
||||||
b {
|
b {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ $wrapper = blockWrapperAttributes( $classes, $is_preview );
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container wider pull-quote__content">
|
<div class="container wider pull-quote__content">
|
||||||
<div class="pull-quote__text">
|
<div class="pull-quote__text <?php if ( is_front_page() ) { echo 'text-balance'; } // phpcs:ignore ?>">
|
||||||
<?php echo wp_kses_post( $quote_text ); ?>
|
<?php echo wp_kses_post( $quote_text ); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "acf/services-list",
|
||||||
|
"title": "Services List",
|
||||||
|
"description": "A row of service cards. Card count (2, 3, or 4) determines the layout.",
|
||||||
|
"category": "vdi-blocks",
|
||||||
|
"style": [
|
||||||
|
"file:./services-list.css"
|
||||||
|
],
|
||||||
|
"icon": "grid-view",
|
||||||
|
"keywords": ["services", "cards"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "services-list.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/* Services List block */
|
||||||
|
|
||||||
|
.services-list {
|
||||||
|
--services-line-top: 22%;
|
||||||
|
|
||||||
|
.container { @apply px-0!; }
|
||||||
|
|
||||||
|
.services-list__line {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: var(--services-line-top);
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
background-image: url('../img/services-list-line.svg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__card {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__media {
|
||||||
|
@apply overflow-hidden rounded-md bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__img {
|
||||||
|
@apply w-full h-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__heading {
|
||||||
|
@apply text-cwc-blue-01 text-32px font-black leading-none mt-6 mb-4;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list__desc {
|
||||||
|
@apply text-gray-600 text-16px font-light leading-snug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.services-list__line {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Block Name: Services List
|
||||||
|
*
|
||||||
|
* A row of service cards. Card count (2, 3, or 4) determines the layout.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$items = get_field( 'items' );
|
||||||
|
|
||||||
|
if ( empty( $items ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = count( $items );
|
||||||
|
$grid_cols = max( 2, min( 4, (int) $count ) );
|
||||||
|
$grid_class = 'lg:grid-cols-' . $grid_cols;
|
||||||
|
|
||||||
|
$classes = 'services-list mx-break-out relative mb-8 lg:mb-12 xl:mb-16 ' . $grid_class;
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section <?php echo wp_kses_post( $wrapper ); ?>>
|
||||||
|
<div class="services-list__line" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="services-list__grid grid grid-cols-1 <?php echo esc_attr( $grid_class ); ?> gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $items as $item ) : ?>
|
||||||
|
<article class="services-list__card">
|
||||||
|
<?php if ( ! empty( $item['image'] ) ) : ?>
|
||||||
|
<figure class="services-list__media">
|
||||||
|
<img
|
||||||
|
class="services-list__img"
|
||||||
|
src="<?php echo esc_url( $item['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $item['image']['alt'] ?? '' ); ?>"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['heading'] ) ) : ?>
|
||||||
|
<h3 class="services-list__heading">
|
||||||
|
<?php echo wp_kses_post( $item['heading'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $item['description'] ) ) : ?>
|
||||||
|
<div class="services-list__desc font-light text-18px">
|
||||||
|
<?php echo wp_kses_post( $item['description'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "acf/team-grid",
|
||||||
|
"title": "Team Grid",
|
||||||
|
"description": "A responsive grid of team members. Click a card to expand the bio.",
|
||||||
|
"category": "common",
|
||||||
|
"style": [
|
||||||
|
"file:./team-grid.css"
|
||||||
|
],
|
||||||
|
"icon": "groups",
|
||||||
|
"keywords": ["team", "people", "staff", "bio"],
|
||||||
|
"acf": {
|
||||||
|
"mode": "preview",
|
||||||
|
"renderTemplate": "team-grid.php"
|
||||||
|
},
|
||||||
|
"supports": {
|
||||||
|
"anchor": true,
|
||||||
|
"align": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/* Team Grid block */
|
||||||
|
|
||||||
|
.team-grid {
|
||||||
|
@apply my-12;
|
||||||
|
|
||||||
|
.team-grid__card { @apply flex flex-col; }
|
||||||
|
|
||||||
|
.team-grid__details { @apply flex flex-col; }
|
||||||
|
|
||||||
|
.team-grid__summary {
|
||||||
|
@apply flex flex-col items-center text-center cursor-pointer list-none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::marker {
|
||||||
|
display: none;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.team-grid__read-bio::after {
|
||||||
|
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%221%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__photo {
|
||||||
|
@apply w-full h-full object-cover rounded-md mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__name {
|
||||||
|
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__title {
|
||||||
|
@apply text-cwc-blue-01 text-16px font-normal italic mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__read-bio {
|
||||||
|
@apply flex gap-2 justify-center items-center text-cwc-blue-01 text-16px font-bold leading-snug mb-1;
|
||||||
|
transition: transform 300ms ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%220.5%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
transition: transform 300ms ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__details[open] .team-grid__read-bio::after {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__bio-wrap {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid__bio { @apply text-16px font-light leading-snug pt-2 text-center text-balance; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Team Grid block — bio expand/collapse animation.
|
||||||
|
*
|
||||||
|
* Native <details>/<summary> handles accessibility (keyboard, screen
|
||||||
|
* reader), but its default toggle behavior hides the children of the
|
||||||
|
* details element synchronously when [open] is removed. That makes
|
||||||
|
* CSS transitions impossible on the close direction, because by the
|
||||||
|
* time the toggle event fires, the element is already hidden.
|
||||||
|
*
|
||||||
|
* This module intercepts the click on the summary, prevents the default
|
||||||
|
* toggle, animates max-height manually, and toggles [open] after the
|
||||||
|
* animation completes. The bio is always at a known state during the
|
||||||
|
* animation.
|
||||||
|
*
|
||||||
|
* The module is enqueued per-block by the team-grid PHP template. It
|
||||||
|
* auto-initializes on import: queries the DOM for any existing
|
||||||
|
* .team-grid__details elements and attaches a click handler to each.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DETAILS_SELECTOR = '.team-grid__details';
|
||||||
|
const BIO_WRAP_SELECTOR = '.team-grid__bio-wrap';
|
||||||
|
const DURATION_MS = 300;
|
||||||
|
|
||||||
|
const setMaxHeight = (el, value) => {
|
||||||
|
el.style.maxHeight = typeof value === 'number' ? `${value}px` : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureBioHeight = (bioWrap) => {
|
||||||
|
// Temporarily allow the inner content to render at its natural size
|
||||||
|
// so scrollHeight reports the real height rather than the clipped height.
|
||||||
|
const previousMax = bioWrap.style.maxHeight;
|
||||||
|
const previousOverflow = bioWrap.style.overflow;
|
||||||
|
|
||||||
|
setMaxHeight(bioWrap, 'none');
|
||||||
|
bioWrap.style.overflow = 'visible';
|
||||||
|
|
||||||
|
const height = bioWrap.scrollHeight;
|
||||||
|
|
||||||
|
bioWrap.style.overflow = previousOverflow;
|
||||||
|
setMaxHeight(bioWrap, previousMax || '');
|
||||||
|
|
||||||
|
return height;
|
||||||
|
};
|
||||||
|
|
||||||
|
const animateOpen = (bioWrap, onComplete) => {
|
||||||
|
const target = measureBioHeight(bioWrap);
|
||||||
|
|
||||||
|
setMaxHeight(bioWrap, '0px');
|
||||||
|
|
||||||
|
// Force a reflow so the browser registers the starting value before
|
||||||
|
// we change it to the target — otherwise the transition is skipped.
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
bioWrap.offsetHeight;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setMaxHeight(bioWrap, `${target}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onEnd = (event) => {
|
||||||
|
if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bioWrap.removeEventListener('transitionend', onEnd);
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bioWrap.addEventListener('transitionend', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const animateClose = (bioWrap, onComplete) => {
|
||||||
|
// The bio is currently open and rendered at its natural height.
|
||||||
|
// Capture it before we change anything.
|
||||||
|
const current = bioWrap.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
setMaxHeight(bioWrap, `${current}px`);
|
||||||
|
|
||||||
|
// Force a reflow so the browser registers the starting value.
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
bioWrap.offsetHeight;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setMaxHeight(bioWrap, '0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onEnd = (event) => {
|
||||||
|
if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bioWrap.removeEventListener('transitionend', onEnd);
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bioWrap.addEventListener('transitionend', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (details, bioWrap, event) => {
|
||||||
|
// Only intercept clicks on the summary (or its descendants).
|
||||||
|
const summary = details.querySelector('summary');
|
||||||
|
if (!summary || !summary.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent the browser's default toggle so we can animate first.
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (details.open) {
|
||||||
|
// Closing: animate to 0, then remove [open] so the children hide.
|
||||||
|
animateClose(bioWrap, () => {
|
||||||
|
details.open = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Opening: set [open] first (so the children are visible), then
|
||||||
|
// measure and animate to the natural height.
|
||||||
|
details.open = true;
|
||||||
|
animateOpen(bioWrap);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attach = (details) => {
|
||||||
|
if (details.dataset.teamGridBound === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
details.dataset.teamGridBound = 'true';
|
||||||
|
|
||||||
|
const bioWrap = details.querySelector(BIO_WRAP_SELECTOR);
|
||||||
|
if (!bioWrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use click (not toggle) so we can preventDefault. Keyboard activation
|
||||||
|
// (Enter/Space on the summary) also dispatches a click event, so this
|
||||||
|
// covers both mouse and keyboard.
|
||||||
|
details.addEventListener('click', (event) => {
|
||||||
|
handleClick(details, bioWrap, event);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
document.querySelectorAll(DETAILS_SELECTOR).forEach(attach);
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Block Name: Team Grid
|
||||||
|
*
|
||||||
|
* A responsive grid of team members. Each card uses a native <details>
|
||||||
|
* element so the bio expands when the photo, name, or title is clicked.
|
||||||
|
*
|
||||||
|
* @package CWC
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace CWC;
|
||||||
|
|
||||||
|
$single = get_field( 'single' );
|
||||||
|
$members = get_field( 'members' );
|
||||||
|
|
||||||
|
if ( empty( $members ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue the per-block JS only when this block actually renders.
|
||||||
|
$js_path = '/views/blocks/team-grid/team-grid.js';
|
||||||
|
if ( file_exists( get_stylesheet_directory() . $js_path ) ) {
|
||||||
|
wp_enqueue_script_module(
|
||||||
|
'cwc-team-grid',
|
||||||
|
get_stylesheet_directory_uri() . $js_path,
|
||||||
|
array(),
|
||||||
|
(string) filemtime( get_stylesheet_directory() . $js_path )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modeSingle = '';
|
||||||
|
|
||||||
|
$blockID = wp_unique_id( 'single-' );
|
||||||
|
|
||||||
|
if ( $single ) {
|
||||||
|
$modeSingle = 'name=' . $blockID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = 'team-grid container my-section mx-auto';
|
||||||
|
$wrapper = blockWrapperAttributes( $classes, $is_preview );
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section <?php echo wp_kses_post( $wrapper ); ?>>
|
||||||
|
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
|
||||||
|
<?php foreach ( $members as $member ) : ?>
|
||||||
|
<article class="team-grid__card">
|
||||||
|
<details class="team-grid__details" <?php echo esc_attr( $modeSingle ); ?>>
|
||||||
|
<summary class="team-grid__summary">
|
||||||
|
<?php if ( ! empty( $member['image'] ) ) : ?>
|
||||||
|
<img
|
||||||
|
class="team-grid__photo"
|
||||||
|
src="<?php echo esc_url( $member['image']['url'] ); ?>"
|
||||||
|
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['name'] ) ) : ?>
|
||||||
|
<h2 class="team-grid__name">
|
||||||
|
<?php echo wp_kses_post( $member['name'] ); ?>
|
||||||
|
</h2>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $member['title'] ) ) : ?>
|
||||||
|
<h3 class="team-grid__title">
|
||||||
|
<?php echo wp_kses_post( $member['title'] ); ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h4 class="team-grid__read-bio" aria-label="Read Bio for <?php echo esc_attr( $member['name'] ); ?>">Read Bio</h4>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="team-grid__bio-wrap">
|
||||||
|
<div class="team-grid__bio-inner">
|
||||||
|
<?php if ( ! empty( $member['bio'] ) ) : ?>
|
||||||
|
<div class="team-grid__bio">
|
||||||
|
<?php echo wp_kses_post( $member['bio'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1 +1,5 @@
|
|||||||
<i class="icon-instagram"></i>
|
<?php $size = $args['size'] ?? '24'; ?>
|
||||||
|
|
||||||
|
<svg width="<?php echo esc_attr( $size ); ?>" height="<?php echo esc_attr( $size ); ?>" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0ZM8.2 6.75H15.8C16.601 6.75 17.25 7.399 17.25 8.2V15.8C17.25 16.601 16.601 17.25 15.8 17.25H8.2C7.399 17.25 6.75 16.601 6.75 15.8V8.2C6.75 7.399 7.399 6.75 8.2 6.75ZM8.95 8.25H15.05C15.4642 8.25 15.8 8.58579 15.8 9V15C15.8 15.4142 15.4642 15.75 15.05 15.75H8.95C8.53579 15.75 8.2 15.4142 8.2 15V9C8.2 8.58579 8.53579 8.25 8.95 8.25ZM12 9.25C10.4812 9.25 9.25 10.4812 9.25 12C9.25 13.5188 10.4812 14.75 12 14.75C13.5188 14.75 14.75 13.5188 14.75 12C14.75 10.4812 13.5188 9.25 12 9.25ZM12 10.75C11.3096 10.75 10.75 11.3096 10.75 12C10.75 12.6904 11.3096 13.25 12 13.25C12.6904 13.25 13.25 12.6904 13.25 12C13.25 11.3096 12.6904 10.75 12 10.75ZM16.1 8.65C16.1 9.06421 15.7642 9.4 15.35 9.4C14.9358 9.4 14.6 9.06421 14.6 8.65C14.6 8.23579 14.9358 7.9 15.35 7.9C15.7642 7.9 16.1 8.23579 16.1 8.65Z" />
|
||||||
|
</svg>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
$width = $args['width'] ?? '11';
|
||||||
|
$height = $args['height'] ?? '19';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<svg width="<?php echo esc_attr( $width ); ?>" height="<?php echo esc_attr( $height ); ?>" viewBox="0 0 11 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Next</title>
|
||||||
|
<path d="M1.5 17.5L9.5 9.5L1.5 1.5" stroke="#F26B53" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
$width = $args['width'] ?? '11';
|
||||||
|
$height = $args['height'] ?? '19';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<svg width="<?php echo esc_attr( $width ); ?>" height="<?php echo esc_attr( $height ); ?>" viewBox="0 0 11 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Previous</title>
|
||||||
|
<path d="M9.5 1.5L1.5 9.5L9.5 17.5" stroke="#F26B53" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
<svg width="29.683" height="31.696" viewBox="0 0 29.683 31.696">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g id="Group_34" data-name="Group 34" transform="translate(-256.083 -695.182)">
|
<path d="M12.25 12.25C13.4167 11.0833 14 9.66667 14 8C14 6.33333 13.4167 4.91667 12.25 3.75C11.0833 2.58333 9.66667 2 8 2C6.33333 2 4.91667 2.58333 3.75 3.75C2.58333 4.91667 2 6.33333 2 8C2 9.66667 2.58333 11.0833 3.75 12.25C4.91667 13.4167 6.33333 14 8 14C9.66667 14 11.0833 13.4167 12.25 12.25ZM8 16C5.76667 16 3.875 15.225 2.325 13.675C0.775 12.125 0 10.2333 0 8C0 5.76667 0.775 3.875 2.325 2.325C3.875 0.775 5.76667 0 8 0C10.2333 0 12.125 0.775 13.675 2.325C15.225 3.875 16 5.76667 16 8C16 8.93333 15.8542 9.8125 15.5625 10.6375C15.2708 11.4625 14.8583 12.2167 14.325 12.9L20 18.6L18.6 20L12.9 14.325C12.2167 14.8583 11.4625 15.2708 10.6375 15.5625C9.8125 15.8542 8.93333 16 8 16Z" fill="#F26B53"/>
|
||||||
<circle id="Ellipse_5" data-name="Ellipse 5" cx="12.181" cy="12.181" r="12.181" transform="translate(257.083 696.182)" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
|
||||||
<line id="Line_8" data-name="Line 8" x2="7.542" y2="7.542" transform="translate(276.809 717.922)" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 810 B |
@@ -14,23 +14,19 @@ $heading = get_field( 'heading' );
|
|||||||
$heroImage = get_field( 'hero_image' );
|
$heroImage = get_field( 'hero_image' );
|
||||||
$heroVector = get_field( 'hero_vector' );
|
$heroVector = get_field( 'hero_vector' );
|
||||||
$intro = get_field( 'intro' );
|
$intro = get_field( 'intro' );
|
||||||
|
$ctas = get_field( 'ctas' );
|
||||||
|
|
||||||
// Fallback for heading
|
// Fallback for heading
|
||||||
if ( ! $heading ) {
|
if ( ! $heading ) {
|
||||||
$heading = getTheTitle();
|
$heading = getTheTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional logic for dark mode (if needed)
|
|
||||||
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
|
|
||||||
$isDark = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The wrapper color is the gradient from .page-hero in page-hero.css. The
|
// 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.
|
// editor's background_color ACF field overrides the gradient with a solid color.
|
||||||
$wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '';
|
$wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-hero-services text-light <?php echo $isDark ? 'dark' : ''; ?>" style="<?php echo esc_attr( $wrapperStyle ); ?>">
|
<div class="page-hero-services text-light" <?php if ( $wrapperStyle ) { // phpcs:ignore ?>style="<?php echo esc_attr( $wrapperStyle ); ?>"<?php } ?>>
|
||||||
<div class="page-hero-services__content container mx-auto px-0!">
|
<div class="page-hero-services__content container mx-auto px-0!">
|
||||||
<!-- <div id="breadcrumbs">
|
<!-- <div id="breadcrumbs">
|
||||||
<?php Breadcrumbs::render(); ?>
|
<?php Breadcrumbs::render(); ?>
|
||||||
@@ -45,6 +41,31 @@ $wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '
|
|||||||
<?php echo wp_kses_post( $intro ); ?>
|
<?php echo wp_kses_post( $intro ); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( $ctas ) : ?>
|
||||||
|
<div class="reset mt-4 sm:mt-6 lg:mt-10 flex flex-wrap justify-center lg:justify-start gap-4">
|
||||||
|
<?php foreach ( $ctas as $index => $cta ) : ?>
|
||||||
|
<?php
|
||||||
|
$ctaLink = $cta['link'];
|
||||||
|
$ctaURL = $ctaLink['url'] ?? '#';
|
||||||
|
$ctaTarget = $ctaLink['target'] ?? '_self';
|
||||||
|
$ctaTitle = $ctaLink['title'] ?? '';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<x-button
|
||||||
|
btnclasses="button text-center min-w-32 dark"
|
||||||
|
element="a"
|
||||||
|
url="<?php echo esc_url( $ctaURL ); ?>"
|
||||||
|
target="<?php echo esc_attr( $ctaTarget ); ?>"
|
||||||
|
title="<?php echo esc_attr( $ctaTitle ); ?>"
|
||||||
|
color="primary"
|
||||||
|
<?php if ( $index !== 0 ) { ?>
|
||||||
|
variant="outline"
|
||||||
|
<?php } ?>
|
||||||
|
></x-button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ( $heroImage ) : ?>
|
<?php if ( $heroImage ) : ?>
|
||||||
|
|||||||
@@ -25,22 +25,33 @@ if ( ! $heading ) {
|
|||||||
$wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '';
|
$wrapperStyle = $bgColor ? 'background-color: ' . esc_attr( $bgColor ) . ';' : '';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-hero <?php echo $isDark ? 'dark' : ''; ?> lg:px-0 lg:mt-30 lg:mb-4" style="<?php echo esc_attr( $wrapperStyle ); ?>">
|
<div class="page-hero <?php echo $isDark ? 'dark' : ''; ?> pt-4 lg:px-0 lg:pt-30 lg:mb-4" style="<?php echo esc_attr( $wrapperStyle ); ?>">
|
||||||
<div class="page-hero__content container flex flex-col lg:flex-row justify-between gap-20 mx-auto px-0!">
|
<div class="page-hero__content container flex flex-col lg:flex-row justify-between gap-6 lg:gap-20 mx-auto lg:px-0!">
|
||||||
<!-- <div id="breadcrumbs">
|
<!-- <div id="breadcrumbs">
|
||||||
<?php Breadcrumbs::render(); ?>
|
<?php Breadcrumbs::render(); ?>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<?php if ( $heading ) : ?>
|
<?php
|
||||||
|
if ( ! is_single() ) :
|
||||||
|
if ( $heading ) :
|
||||||
|
?>
|
||||||
<h1 class="page-hero__heading flex-0">
|
<h1 class="page-hero__heading flex-0">
|
||||||
<?php echo wp_kses_post( $heading ); ?><em>.</em>
|
<?php echo wp_kses_post( $heading ); ?><em>.</em>
|
||||||
</h1>
|
</h1>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ( $intro ) : ?>
|
<?php if ( $intro ) : ?>
|
||||||
<div class="page-hero__intro grow text-18px/6 pt-3">
|
<div class="page-hero__intro grow text-18px mb-6">
|
||||||
<?php echo wp_kses_post( $intro ); ?>
|
<?php echo wp_kses_post( $intro ); ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php
|
||||||
|
endif;
|
||||||
|
else :
|
||||||
|
?>
|
||||||
|
<a href="<?php echo esc_url( get_permalink( get_option( 'page_for_posts' ) ) ); ?>" class="page-hero__back-link flex items-center gap-2 text-20px font-bold text-cwc-blue-03! hover:text-cwc-blue-01!">
|
||||||
|
<span class=""><?php get_template_part( 'views/icons/prev', null, array( 'width' => 7, 'height' => 12 ) ); // phpcs:ignore ?></span>
|
||||||
|
<span>Back</span>
|
||||||
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user