docs(blog): add blog layout spec and plan

This commit is contained in:
Keith Solomon
2026-07-04 20:25:20 -05:00
parent 54608c2be4
commit a26b5c66c1
2 changed files with 548 additions and 0 deletions
@@ -0,0 +1,370 @@
# 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
<?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**
```bash
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:
```css
@import './post-list.css';
```
- [ ] **Step 2: Replace `styles/components/post-list.css` with the new content**
```css
/* 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**
```bash
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**
```bash
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`**
```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**
```bash
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)**
```bash
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).
@@ -0,0 +1,178 @@
# Blog Index Layout
## Goal
Build the blog index layout shown in `blog-mockup.png` (theme root) so `/blog/` and any other view that uses the WordPress `index.php` template (category, tag, author, search) render with a clean design: a "Blog" page title at the top, a grid of post cards (each card shows image, title, and byline only), and the existing sidebar logic preserved on the right side.
## Scope
- Applies to `index.php` only — the blog index, category archives, tag archives, author archives, and search results all fall through this template in the current theme (no `archive.php`, `category.php`, `tag.php`, `author.php`, or `home.php` exist).
- Does **not** change `single.php` (single-post view) or `page.php` (static pages). The Services hero gate is unaffected.
- Does **not** change `front-page.php` (the home page).
- The existing post list component (`.post-list__posts`, `.post-list__post`, `.post-list__title`, `.post-list__byline`) is preserved and used.
- The existing sidebar logic (`hasSidebar()` → 4-col grid with sidebar) is preserved.
- The existing pagination (`the_posts_pagination`) is preserved.
## Architecture
The change is a small refactor of `index.php` and an unfreeze of `styles/components/post-list.css`:
1. **`index.php`** — add a "Blog" page title at the top of the post list section, and trim the post card to image + title + byline (remove the categories row and the 15-word excerpt). The grid, sidebar, and pagination remain unchanged.
2. **`styles/components/post-list.css`** — unfreeze the file (currently empty boilerplate) and add styles for the new `.post-list__h1` heading. Use the project's existing H1 typography (Quincy serif, fluid `--h1` size, default margin).
3. **A new test** in `tests/blog-page.spec.js` — verifies the blog renders the page title, the post grid, and no inner-page hero. Mirrors the structure of `tests/contact-page.spec.js`.
The post list grid is already implemented with `grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]` (a responsive auto-fit grid that gives 3-4 columns on desktop, fewer on smaller viewports). The mockup shows 3 columns at desktop, which is what the auto-fit grid gives at ~1280px. No change to the grid layout.
## ACF Field Group Changes
None. The blog index uses the standard WordPress post data (`the_title`, `the_permalink`, `the_author`, `the_date`, `get_the_post_thumbnail_url`, `get_the_category`). No custom fields.
## index.php Wiring
Find the post list section:
```php
<?php if ( have_posts() ) : ?>
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
<div class="post-list">
<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 ...">
<figure class="post-list__img ...">
... (featured image)
</figure>
<div class="post-list__details px-4 py-8 flex flex-col grow">
<div class="post-list__cats">
Posted in: ... (categories)
</div>
<a href="<?php the_permalink(); ?>" class="">
<h2 class="post-list__title ..."><?php the_title(); ?></h2>
</a>
<div class="post-list__excerpt mb-6">
<?php customExcerpt( get_the_content(), 15 ); ?>
</div>
<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( ... ); ?>
</div>
</div>
</div>
<?php else : ?>
...
<?php endif; ?>
```
Changes:
1. **Add a page title** at the top of the post list, after the `<div class="post-list">` opening tag:
```php
<div class="post-list">
<h1 class="post-list__h1">Blog</h1>
<div class="post-list__posts grid ...">
...
</div>
...
</div>
```
The title text is hard-coded "Blog" — this matches the spec's "Standard H1 (Quincy, project default)" answer. The title is the same on category, tag, author, and search views (the mockup only shows the blog index, but consistency is the simplest choice for now; a future task can add per-view titles if needed).
2. **Remove the categories row** (`<div class="post-list__cats">...`).
3. **Remove the 15-word excerpt** (`<div class="post-list__excerpt mb-6">...`).
4. **Keep the byline** (`<div class="post-list__byline mt-auto">...`).
5. **Tighten the card's details padding** — the current `px-4 py-8` leaves a lot of empty space when there's no excerpt. Change to `px-4 py-6` (or whatever the design lands on after visual review; spec this as `px-4 py-6`).
6. **Empty state** — when no posts are found, the "Nothing here yet..." heading still renders. This is the existing behavior; no change. (The `else` branch is unchanged.)
7. **`customExcerpt()` helper** is now unused in `index.php`. The helper itself (in `lib/extras.php` or `lib/helpers.php`) is left as-is — other templates may use it. Just don't call it from `index.php`.
## post-list.css Wiring
The file is currently empty boilerplate. Add:
```css
/* 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;
}
```
The `color: var(--color-cwc-blue-01)` matches the contact-info heading and the inner-page hero heading for visual consistency. The other values are the project's existing H1 typography tokens (Quincy serif, fluid `--h1` size, default 700 weight).
**No other changes** to `post-list.css`. The card styling (border, shadow, padding) is currently inline via Tailwind utility classes in `index.php`; the spec leaves that as-is.
## Data Flow
- **Editor flow:** the blog index uses standard WordPress post data. Editors write posts in the WP admin, set a featured image, assign categories, and publish. The blog renders whatever is published.
- **Render flow:** WordPress routes `/blog/`, `/category/...`, `/tag/...`, `/author/...`, and `/?s=...` (search) to `index.php`. The template loops over the matching posts, renders each as a card, and renders pagination at the bottom. The sidebar is rendered when `hasSidebar()` returns true.
- **Per-page variation:** none — the design is fixed.
## Error Handling and Edge Cases
- **No posts found** → the "Nothing here yet..." empty state renders.
- **Pagination** → preserved; renders at the bottom of the post list.
- **Featured image missing** → the existing `picsum.photos` fallback is preserved. (The current code has `get_the_post_thumbnail_url() ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();`. This is a hack — a real fallback would be a project default image. Out of scope for this task.)
- **Page title when there are no posts** → still renders "Blog" (the title is unconditional). This is fine — a user navigating to an empty category should still know what page they're on.
- **Search results** → the "Blog" title is misleading for search. A future task can add a per-view title (`is_search() ? 'Search Results' : 'Blog'`). Out of scope here.
- **Hero** → the `header.php` change from the previous turn (`$showHero = isServicesDescendant() && get_field( 'hero_style' ) === 'default'`) already ensures the blog renders without the inner-page hero. This task doesn't touch `header.php`.
## Out of Scope
- New ACF fields for posts.
- New archive templates (`archive.php`, `category.php`, `tag.php`, `author.php`).
- Single-post view (`single.php`) changes.
- A proper fallback image (the `picsum.photos` URL is a known wart but not a blocker).
- Pagination restyling (the existing `the_posts_pagination()` is fine).
- Changing `front-page.php` (the home page).
- Sidebar widget changes (the existing `sidebar.php` works as-is).
- A per-view page title (category-specific, tag-specific, etc.). The current spec hard-codes "Blog" everywhere.
## Verification
- Add `tests/blog-page.spec.js` covering:
- Desktop (1280px): the blog page renders with the "Blog" H1, the post grid, no inner-page hero, the sidebar (when `hasSidebar()` is true).
- Mobile (402px): the post grid stacks to a single column, no horizontal overflow, the page title is visible.
- 320px: nothing clips.
- Axe scan: no violations.
- No console errors.
- Each post card has: image, title link, byline (author + date). Does NOT have a categories row or an excerpt.
- Run `npm run build` to regenerate `static/dist/`.
- Run the full Playwright suite (`npx playwright test`) including `tests/mobile-homepage.spec.js`, `tests/site-a11y.spec.js`, `tests/inner-page.spec.js`, `tests/contact-page.spec.js`, `tests/services-hero-scope.spec.js`, and the new `tests/blog-page.spec.js`.
- Run `composer lint` to catch PHPCS issues.
- Capture a 1280px full-page screenshot of `/blog/` for visual comparison with `blog-mockup.png`.
## Acceptance Criteria
1. `/blog/` renders a "Blog" page title (Quincy serif, CWC Blue 01, the project's standard H1 style).
2. Each post card on `/blog/` shows: featured image, title, byline (author + date). No categories row. No excerpt.
3. The existing sidebar logic is preserved: when `hasSidebar()` is true, the post grid is in the main column and the sidebar is in the 4th column of a 4-col grid.
4. Category, tag, author, and search views (which all use `index.php`) render with the same design — page title + post grid + sidebar.
5. No inner-page hero is rendered on the blog (this was the previous turn's change; preserved here).
6. No console errors, axe violations, or PHPCS errors.
7. The new blog test passes; existing tests continue to pass with no regressions.