15 KiB
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.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.
Reference Mockup
Mapping-Services.png shows three content-area sections on the Mapping services page (URL /services/mapping/):
- 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.
- 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.
- 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:
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:
<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-Nclass is generated dynamically. Tailwind v4 picks up the class from the source file and emits the rule. No safelist needed. - Card aspect ratio:
aspect-squarefor 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-25px 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)
{
"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 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-bgif 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--mobileprovides 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:
- Add a
Services Listblock, 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 twoServices Listblocks on the mapping page: one with 2 items, one with 4 items. Each block renders as a single row. - Add a
Pull Quoteblock, 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:
@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:
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.phpalready suffices. - Per-item "size" override (the size is fully driven by item count).
- A custom layout for the closing block —
pull-quotecovers it. - An editor-uploadable linework SVG — the line is a fixed asset.
- Mobile-specific layout — the responsive
grid-cols-1 lg:grid-cols-Nhandles it. The bisecting line is hidden on mobile (belowlg:) 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-Nclass, 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.