Compare commits

..
18 Commits
Author SHA1 Message Date
Keith Solomon f0b5bf75a5 feature: Add services list block
Deploy to Dreamhost (dev) / build (push) Successful in 39s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-25 10:37:28 -05:00
Keith Solomon 60d764045f 🐞 fix: Remove dead $line_url variable from services-list template 2026-07-25 00:18:40 -05:00
Keith Solomon 9c4553f3d0 test: Add assertion for dynamic grid column class 2026-07-25 00:16:26 -05:00
Keith Solomon b0bad0d273 🐞 fix: Remove redundant inline style on services-list line div 2026-07-25 00:16:03 -05:00
Keith Solomon 9c3a645368 📝 docs: Sync spec and plan to use text-24px after the build fix 2026-07-25 00:15:47 -05:00
Keith Solomon d2b2a04b56 test: Add Playwright tests for services-list block 2026-07-24 23:59:28 -05:00
Keith Solomon 7663ca63f6 📝 docs: Fix Task 7 Step 2 grep to match CSS-nesting unwrap 2026-07-24 23:57:25 -05:00
Keith Solomon 415934e0bf 📝 docs: Fix Task 7 verification greps for Windows Git-Bash 2026-07-24 23:54:15 -05:00
Keith SolomonandClaude dae3e7fd68 🎨 build: Rebuild dist after services-list heading token fix
Rebuild static/dist/theme.css to reflect the text-25px → text-24px
fix in views/blocks/services-list/services-list.css. The
.services-list__heading rule now resolves cleanly in Tailwind v4.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 23:52:33 -05:00
Keith SolomonandClaude 2c917edf20 🐞 fix: Use registered text-24px token in services-list heading
The text-25px literal is not a registered token in the project's
typography scale (--text-*). Replace with the closest existing
token, text-24px, to satisfy Tailwind v4 @apply resolution.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 23:51:57 -05:00
Keith Solomon 0be6800d88 🔧 chore: Import services-list block CSS into theme 2026-07-24 23:43:24 -05:00
Keith Solomon 61f959e1a2 feat: Add services-list block PHP template 2026-07-24 23:39:51 -05:00
Keith Solomon a13a3adeef 📝 docs: Fix brief Task 4 verification grep for CSS nesting 2026-07-24 23:34:39 -05:00
Keith Solomon 8c52946340 feat: Add services-list block CSS 2026-07-24 23:32:06 -05:00
Keith Solomon a628973ed8 feat: Add services-list block.json registration 2026-07-24 23:28:25 -05:00
Keith Solomon b32abfb8f6 feat: Add services-list decorative line SVG 2026-07-24 23:24:13 -05:00
Keith Solomon 07fea9e57b feat: Add ACF field group for services-list block 2026-07-24 23:21:14 -05:00
Keith Solomon c8832f49b2 📝 docs: Add design spec for services content blocks 2026-07-24 23:11:28 -05:00
10 changed files with 7139 additions and 0 deletions
+123
View File
@@ -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,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,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.
+5899
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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
View File
@@ -5,3 +5,4 @@
@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';
+36
View File
@@ -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([]);
});
});
+19
View File
@@ -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: 32px;
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%;
}
.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-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;
}
}
.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>