Files
CWC/docs/superpowers/plans/2026-07-24-services-content-blocks.md
T

23 KiB
Raw Blame History

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:

{
    "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 #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 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.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:

{
    "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. 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:

/* 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 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
/**
 * 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 @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:

@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.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

Run: grep -c "services-list__heading" static/dist/theme.css Expected: 1 or more (the class should appear in the built CSS).

  • Step 3: Verify the dynamic grid classes are in the built output

Run: grep -E "lg\\\\:grid-cols-(2|3|4) " static/dist/theme.css | wc -l Expected: 3 (all three column counts should be present).

  • 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.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:

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 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.