Files
CWC/docs/superpowers/plans/2026-07-04-blog-layout.md

15 KiB

Blog Index Layout 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 blog index layout shown in blog-mockup.png so /blog/ and any other view using index.php render a "Blog" page title, a grid of post cards (image + title + byline), and the existing sidebar.

Architecture: A small refactor of index.php (add a page title, trim the post card) and an unfreeze of styles/components/post-list.css (add .post-list__h1 styles). A new Playwright test verifies the result. The existing sidebar logic, pagination, and post grid layout are preserved.

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, see composer.json and existing files). The brief's canonical PHP for the index.php changes should use tabs.
  • The theme's Tailwind build outputs static/dist/theme.css; that file is gitignored but committed with git add -f per project convention.
  • PHPCS uses the WordPress coding standard; composer lint must pass. Note: the previous contact-page work surfaced 5 pre-existing PHPCS CRLF errors on untouched files — they are out of scope and not introduced by this plan.
  • All Playwright tests in tests/*.spec.js must pass except the 4 pre-existing mobile-homepage.spec.js failures (out of scope) and the still-broken services-hero-scope ACF data state (separate user follow-up).
  • The new index.php change applies to all views that route through it: /blog/, category archives, tag archives, author archives, and search results. No new template files are created.
  • The post card content is simplified to image + title + byline (no categories, no excerpt). The card's px-4 py-8 padding is tightened to px-4 py-6.
  • The page title is hard-coded "Blog" (project's standard H1 styling: Quincy serif, CWC Blue 01, fluid --h1 size, 700 weight). The title is the same on every view that uses index.php.
  • The hero gate (set in the previous turn: $showHero = isServicesDescendant() && get_field( 'hero_style' ) === 'default') is unaffected.

File Structure

File Responsibility Created/Modified
index.php Renders the blog index, category/tag/author archives, and search results. Add a "Blog" H1 and trim the post card. Modify
styles/components/post-list.css Unfreeze the file and add .post-list__h1 styles. Modify
tests/blog-page.spec.js New Playwright spec for the blog layout. Create
static/dist/theme.css Rebuilt via npm run build. Modified (committed with git add -f).

Task 1: Update index.php — add page title, trim the post card

Files:

  • Modify: index.php

Interfaces:

  • Consumes: the standard WordPress loop (have_posts(), the_post()).

  • Produces: a <section> with the existing sidebar-aware grid; a <h1 class="post-list__h1">Blog</h1> at the top of the post list; a card per post with image, title link, and byline (no categories, no excerpt).

  • Step 1: Read index.php to confirm the current structure

The file has been read in this session (it currently renders .post-list__posts with cards containing categories, title, excerpt, and byline). Confirm the line numbers and class names match the spec.

  • Step 2: Add the H1 and trim the card

Replace the post-list opening + card inner content with the new structure:

<?php if ( have_posts() ) : ?>
    <div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
        <div class="post-list">
            <h1 class="post-list__h1">Blog</h1>

            <div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6">
                <?php
                while ( have_posts() ) :
                    the_post();
                    ?>
                    <div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500">
                        <figure class="post-list__img aspect-video border-b border-secondary rounded-t-md block h-auto w-full overflow-hidden m-0 p-0">
                            <?php if ( has_post_thumbnail() ) : ?>
                                <?php
                                    $featImg = get_the_post_thumbnail_url();
                                    $postImg = $featImg ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();
                                    $imgAlt  = get_post_meta( get_post_thumbnail_id(), '_wp_attachment_image_alt', true ) ?? get_the_title();
                                ?>

                                <img class="block h-full object-cover transition-transform duration-300 ease-linear w-full will-change-transform" src="<?php echo esc_url( $postImg ); ?>" alt="<?php echo esc_attr( $imgAlt ); ?>">
                            <?php endif; ?>
                        </figure>

                        <div class="post-list__details px-4 py-6 flex flex-col grow">
                            <a href="<?php the_permalink(); ?>" class="">
                                <h2 class="post-list__title font-normal text-25px text-balance line-clamp-4 truncate mt-0 mb-5 mx-0"><?php the_title(); ?></h2>
                            </a>

                            <div class="post-list__byline mt-auto">
                                <span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> &mdash;</span>
                                <span class="post-list__date"><?php echo get_the_date(); ?></span>
                            </div>
                        </div>
                    </div>
                <?php endwhile; ?>
            </div>

            <div class="post-list__pagination">
                <?php
                the_posts_pagination(
                    array(
                        'mid_size'  => 2,
                        'prev_text' => '&laquo; Previous',
                        'next_text' => 'Next &raquo;',
                    )
                );
                ?>
            </div>
        </div>
    </div>
<?php else : ?>
    <div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
        <div class="no-posts">
            <h2 class="no-posts__title">Nothing here yet&hellip;</h2>
            <p class="no-posts__desc">No published posts found.</p>
        </div>
    </div>
<?php endif; ?>

Specifically:

  • Add <h1 class="post-list__h1">Blog</h1> after the <div class="post-list"> opening tag.
  • Remove the <div class="post-list__cats"> block.
  • Remove the <div class="post-list__excerpt mb-6"> block.
  • Change post-list__details px-4 py-8 to post-list__details px-4 py-6.
  • Do NOT touch the <div class="post-list__byline mt-auto"> block.
  • Do NOT touch the empty state (else branch).
  • Do NOT touch the pagination block.

Indentation: tabs. The current index.php uses tabs.

  • Step 3: Verify the file parses

Run: php -l index.php Expected: No syntax errors detected in index.php.

  • Step 4: Commit
git add index.php
git commit -m "feat(blog): add page title and trim post card to image + title + byline"

Task 2: Unfreeze styles/components/post-list.css and add .post-list__h1

Files:

  • Modify: styles/components/post-list.css

Interfaces:

  • Consumes: the new <h1 class="post-list__h1"> from Task 1.

  • Produces: a styled H1 that matches the project's H1 typography (Quincy serif, CWC Blue 01, fluid --h1 size, 700 weight, default margin).

  • Step 1: Verify the import in styles/components/index.css is already present

Run: grep -n "post-list" styles/components/index.css Expected: a matching line. If not, add:

@import './post-list.css';
  • Step 2: Replace styles/components/post-list.css with the new content
/* Blog/post index listing styles */

.post-list__h1 {
    color: var(--color-cwc-blue-01);
    font-family: var(--font-quincy, 'Quincy', serif);
    font-size: var(--h1);
    font-weight: 700;
    line-height: 1.2;
    margin: 0 0 2rem;
    text-align: left;
}
  • Step 3: Commit
git add styles/components/post-list.css styles/components/index.css
git commit -m "feat(blog): style the post-list page title"

Task 3: Build the dist CSS

Files:

  • Modify: static/dist/theme.css (committed with git add -f).

  • Step 1: Run the build

Run: npm run build Expected: build completes without errors.

  • Step 2: Verify the new class is in the dist

Run: grep -c "post-list__h1" static/dist/theme.css Expected: at least 1 match.

  • Step 3: Commit the rebuilt dist
git add -f static/dist/theme.css
git commit -m "build: regenerate dist with blog post-list styles"

Task 4: Add tests/blog-page.spec.js

Files:

  • Create: tests/blog-page.spec.js

Interfaces:

  • Consumes: the rendered /blog/ page.

  • Produces: a Playwright spec that verifies the page title, the post grid, the absence of categories/excerpt, the sidebar, the absence of an inner-page hero, and no console errors or axe violations.

  • Step 1: Create tests/blog-page.spec.js

import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;

const blogUrl =
    process.env.TEST_BLOG_URL ||
    "http://community-works-collaborative.test/blog/";

test.describe("blog page", () => {
    test("desktop: Blog title, post grid, no inner-page hero, sidebar", async ({
        page,
    }) => {
        const pageErrors = [];
        page.on("pageerror", (err) => pageErrors.push(err.message));

        await page.setViewportSize({ width: 1280, height: 900 });
        await page.goto(blogUrl);

        // No inner-page hero.
        expect(await page.locator(".page-hero").count()).toBe(0);

        // Page title is "Blog".
        const heading = page.locator(".post-list__h1").first();
        await expect(heading).toBeVisible();
        await expect(heading).toHaveText("Blog");

        // Post grid renders at least one card.
        const posts = page.locator(".post-list__post");
        const postCount = await posts.count();
        expect(postCount).toBeGreaterThanOrEqual(1);

        // First card: image, title link, byline.
        const firstCard = posts.first();
        await expect(firstCard.locator(".post-list__img img")).toBeVisible();
        await expect(firstCard.locator(".post-list__title")).toBeVisible();
        await expect(firstCard.locator(".post-list__byline")).toBeVisible();

        // First card does NOT have a categories row or an excerpt.
        expect(await firstCard.locator(".post-list__cats").count()).toBe(0);
        expect(await firstCard.locator(".post-list__excerpt").count()).toBe(0);

        // Sidebar renders.
        await expect(page.locator("#secondary, .sidebar, aside").first()).toBeAttached();

        // No JS errors.
        expect(pageErrors).toEqual([]);
    });

    test("mobile 402: single-column grid, no horizontal overflow", async ({
        page,
    }) => {
        await page.setViewportSize({ width: 402, height: 874 });
        await page.goto(blogUrl);

        const heading = page.locator(".post-list__h1").first();
        await expect(heading).toBeVisible();

        const headingBox = await heading.boundingBox();
        const block = page.locator(".post-list").first();
        const blockBox = await block.boundingBox();

        expect(headingBox).not.toBeNull();
        expect(blockBox).not.toBeNull();
        expect(headingBox.x + headingBox.width).toBeLessThanOrEqual(402 + 3);
        expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(402 + 3);
    });

    test("mobile 320: nothing clips horizontally", async ({ page }) => {
        await page.setViewportSize({ width: 320, height: 800 });
        await page.goto(blogUrl);

        const block = page.locator(".post-list").first();
        await expect(block).toBeVisible();

        const blockBox = await block.boundingBox();
        expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
    });

    test("axe: no violations", async ({ page }, testInfo) => {
        await page.setViewportSize({ width: 1280, height: 900 });
        await page.goto(blogUrl);

        const results = await new AxeBuilder({ page })
            .withTags([
                "wcag2a",
                "wcag2aa",
                "wcag21a",
                "wcag21aa",
                "wcag22a",
                "wcag22aa",
            ])
            .analyze();

        await testInfo.attach("accessibility-scan-results", {
            body: JSON.stringify(results, null, 2),
            contentType: "application/json",
        });

        expect(results.violations).toEqual([]);
    });
});
  • Step 2: Run the new test

Run: npx playwright test tests/blog-page.spec.js Expected: all 4 tests pass. The blog URL is http://community-works-collaborative.test/blog/; if the live site has no published posts, the test may fail with postCount === 0. In that case, the user needs to seed a published post (or set TEST_BLOG_URL to a URL with posts).

  • Step 3: Commit
git add tests/blog-page.spec.js
git commit -m "test(blog): add blog-page Playwright spec"

Task 5: Run the full Playwright suite + PHPCS

  • Step 1: Run the full Playwright suite

Run: npx playwright test Expected: all tests pass except:

  • 4 pre-existing mobile-homepage.spec.js failures (out of scope).
  • The 2 services-hero-scope ACF-data failures (separate user follow-up; this plan does not introduce them).
  • The 1 services-hero-scope test.fail() grandchild (already handled).
  • The 6 inner-page regressions (user follow-up: set CWC_TEST_BYPASS_HERO_GATE=true in wp-config.php).

The new tests/blog-page.spec.js should pass.

  • Step 2: Run PHPCS

Run: composer lint Expected: no NEW errors. The 5 pre-existing CRLF errors on untouched files remain (out of scope).

  • Step 3: Verify the dist is up to date

Run: git status static/dist/theme.css Expected: nothing to commit, working tree clean. If uncommitted, run git add -f static/dist/theme.css and commit with chore: sync dist after lint/fix.

  • Step 4: Final commit (if anything changed in step 1-3)
git add -A
git commit -m "chore: final pass after Playwright and PHPCS"

Self-Review Notes

  • Spec coverage: Task 1 (index.php H1 + card trim), Task 2 (post-list.css), Task 3 (build), Task 4 (test), Task 5 (final suite + PHPCS). Every spec section is covered.
  • Placeholder scan: No "TBD", "TODO", or "implement later" in any step. The picsum.photos fallback is a known wart but it's in the existing code (out of scope).
  • Type consistency: Class names match the BEM convention in the spec.
  • Spec section "Out of Scope" (per-view titles, new templates, single-post view, sidebar widgets, fallback image, pagination restyling, front-page.php, archive/category/tag/author templates): all left as-is.
  • Indentation: tabs for PHP (per the previous contact-page work, the project standard).