24 KiB
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.cssis committed (not gitignored) per project convention.- PHPCS uses the WordPress coding standard;
composer lintmust 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
::afterunderline from.post-title h1instyles/base/misc.css) for consistency. Do not introduce a new accent style. - Reuse the existing
pull-quoteblock for the closing statement — no duplicate block. - Page template
page.phpis not modified. The page-children block is not used. - This work does not edit
header.php,footer.php,views/partials/page-hero.php, orviews/partials/page-hero-services.php. - Tailwind v4 picks up class names from source files at build time. The dynamic
lg:grid-cols-Nclass needs to literally appear in the source PHP for Tailwind to emit the rule. - The
static/img/services-list-line.svgis 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 toblock == 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:
{
"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
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
#F26B53rectangle,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 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
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.jsondeclaringacf/services-listas the block name,services-list.phpas the render template,grid-viewicon,commoncategory.regACFBlocks()infunctions.phpwill 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:
{
"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
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. Reusestext-cwc-blue-01andvar(--color-secondary)from existing tokens. Renders the bisecting line viabackground-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:
/* 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-25px 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:
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
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 withimage(array:url,alt),heading(string),description(string). -
Produces: A
<section class="services-list ...">element containing the bisecting__linediv, acontainerwith a grid div, and a<article class="services-list__card">per item. Computes the dynamiclg:grid-cols-Nclass 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
/**
* 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
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
@importline at the end ofstyles/blocks/index.cssso 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:
@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
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.cssthat includes the.services-list*rules and the dynamiclg:grid-cols-2,lg:grid-cols-3,lg:grid-cols-4utilities. -
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:
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:
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
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.phptemplate (Tasks 4 + 5) on the WordPress page/services/mapping/. - Produces: Three tests:
- Card count, image, and heading visibility per block (expects 2 + 4 cards across two block instances).
- Bisecting
__lineis present andaria-hidden="true". - No axe-core a11y violations scoped to
.services-list.
Prerequisite: the test depends on the mapping page being composed in Gutenberg with two
Services Listblocks (2 items + 4 items) and aPull Quoteblock. 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:
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
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
itemswithimage/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: 1on line,z-index: 2on grid, media query to hide on<lg). - Reuse
pull-quotefor closing — explicitly out of scope (no code change needed). - Page template not modified — confirmed;
page.phpis 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.
- One new ACF block (
- 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 namesimage,heading,descriptionare used in Task 1 (defined) and Task 5 (consumed). Class namesservices-list,__line,__grid,__card,__media,__img,__heading,__descare 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.