Files
Portfolio-2026/docs/superpowers/plans/2026-08-16-portfolio-2026-implementation.md
T

2373 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Portfolio 2026 Theme 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:** Rebuild the surface of the `ks-portfolio` WordPress theme to match the Portfolio 2026 identity (Carbon Blue dark atmosphere + Raleway typography + project catalog focused on GitHub/Gitea-backed Projects), resolve the documented source conflicts, and ship a working tested theme.
**Architecture:** SoloFrame Evo base (Tailwind 4 + ACF blocks + namespaced PHP classes), namespaced from `SoloFrameEvo``KsPortfolio`. The theme is a pure presentation layer over the installed `projects-portfolio` plugin — no new CPT registration. Tokens drive every color/typography decision; templates pull data from WordPress content and the plugin's `projects_portfolio_get_*()` helpers with empty-state fallbacks.
**Tech Stack:** PHP 8.1+ (WordPress 6.x), Tailwind CSS 4, ACF Pro (Secure Custom Fields), Playwright + axe-core, PHPCS + WPCS, Raleway (Google Fonts).
## Global Constraints
- PHP namespace: `KsPortfolio` (was `SoloFrameEvo`). Every PHP file in the theme uses this namespace. Composer text-domain: `ks-portfolio`.
- Text domain: `ks-portfolio` for every `__()`, `_e()`, `esc_html__()`, etc. call. Theme slug in `style.css` header matches.
- Tokens only: no raw hex outside `theme.json` editor palette, `styles/base/colors.css`, or the Carbon Blue partial. Reference via `var(--color-*)` / `var(--font-*)` / `var(--text-*)`.
- `--font-headings: var(--font-sans)` is defined in `styles/base/typography.css`.
- Body surface: `background-color: #100e0b; color: var(--color-on-surface);`. The light `main#maincontent { background: var(--color-background); }` rule is removed.
- Carbon Blue atmospheric field lives in `views/partials/aura-bg.php` and is loaded on `front-page.php` and `archive-projects.php`. The container must NOT carry `background-color` (blend-mode architecture).
- Decorative layers: `position: absolute; inset: 0; pointer-events: none; aria-hidden="true"`.
- Accessibility target: WCAG 2.1 AA. Every template has exactly one `<h1>`. Skip link preserved. `prefers-reduced-motion` honored.
- Security: all forms use `wp_nonce_field`; state-changing requests use `wp_verify_nonce`; dynamic output uses the right `esc_*` for context; external URLs `esc_url` + `rel="noopener noreferrer"` when `target="_blank"`.
- No `picsum.photos` URLs anywhere in shipped theme.
- No invented project copy, statistics, or testimonials. Empty states use the documented copy: `Nothing here yet...`, `No published projects found.`, `Page Not Found`, `Sorry, the page you're looking for doesn't exist.`
- Test scope: Chromium + axe only. Firefox / WebKit / mobile are out of scope. OWASP ZAP out of scope — security audit is PHPCS + grep + manual review.
- Local base URL: `http://portfolio.test` (matches `.env`).
---
## File Structure
### Modified
- `style.css` — already correct; verify only.
- `functions.php` — namespace swap, include new `class-projects.php`, include new aura-bg partial bootstrap.
- `header.php` — namespace swap, dark surface, three-region responsive header, aura-bg on home/archive.
- `footer.php` — namespace swap, dark surface, footer links match Carbon Blue.
- `front-page.php` — full home template replacing 8-line stub.
- `index.php` — namespace swap, dark surface, remove `picsum.photos`, empty-state copy.
- `single.php` — namespace swap, dark surface, remove `picsum.photos`.
- `page.php` — namespace swap, dark surface.
- `search.php` — namespace swap, dark surface, remove `picsum.photos`.
- `404.php` — namespace swap, dark surface, documented empty-state copy.
- `lib/activation.php` — namespace swap.
- `lib/class-acf.php` — namespace swap.
- `lib/class-breadcrumbs.php` — namespace swap.
- `lib/class-enqueue.php` — namespace swap, verify Raleway + theme.css enqueue.
- `lib/class-menuitems.php` — namespace swap.
- `lib/class-resources.php` — namespace swap (keep CPT registration as-is).
- `lib/extras.php` — namespace swap.
- `lib/helpers.php` — namespace swap, add `getTheTitle()`, `hasSidebar()`, `customExcerpt()`, `getFieldValue()`.
- `lib/hooks.php` — namespace swap.
- `lib/search-features.php` — namespace swap.
- `lib/show-template.php` — namespace swap.
- `theme.json` — fix `styles` references to existing slugs, add dark surface palette.
- `styles/base/colors.css` — add Carbon Blue tokens.
- `styles/base/typography.css` — define `--font-headings`.
- `styles/base/global.css` — dark body, remove light `main` rule, dark container / section tweaks.
- `styles/base/forms.css` — dark form surfaces.
- `styles/navigation/nav-*.css` — dark link colors.
- `styles/blocks/buttons.css` — verify dark-surface contrast for primary/secondary buttons.
- `views/components/nav-aux.php`, `nav-main.php`, `nav-main__toggle.php` — namespace swap.
- `views/partials/page-hero.php` — namespace swap, dark surface defaults.
- `views/partials/social-media.php` — namespace swap, dark color overrides.
- `views/forms/search.php` — escape audit only.
- `playwright.config.js` — set base URL, keep chromium only.
### New
- `archive-projects.php` — project archive template.
- `single-projects.php` — project detail template.
- `taxonomy-project-type.php` — project type archive template.
- `views/partials/aura-bg.php` — Carbon Blue atmospheric field.
- `views/components/project-card.php` — reusable project card.
- `views/components/project-stat-card.php` — Downloads/Stars/Forks/Issues tile.
- `views/components/project-meta-row.php` — repository metadata table row.
- `lib/class-projects.php` — thin wrapper around plugin helpers + provider-aware defaults.
- `tests/home.spec.js` — Playwright + axe on home.
- `tests/archive.spec.js` — Playwright + axe on project archive.
- `tests/single.spec.js` — Playwright + axe on project detail.
- `docs/superpowers/specs/2026-08-16-portfolio-2026-design.md` — already committed.
- `docs/superpowers/specs/2026-08-16-portfolio-2026-testing-report.md` — final report (delivered at end).
### Removed
- `screenshot.png` (Basic WP) — replaced at end.
- `picsum.photos` URLs in `index.php`, `search.php`.
- Legacy hex values in pagination CSS.
---
## Task 1: Verify environment + capture baseline screenshots
**Files:**
- Inspect: `playwright.config.js`
- Inspect: `.env`
- Inspect: `static/dist/theme.css`
- Inspect: `wp-content/plugins/projects-portfolio/` (installed)
**Goal:** Confirm the local site at `http://portfolio.test` is reachable, the existing Playwright suite runs, and capture a baseline of each mockup reference page so we can diff later.
- [ ] **Step 1: Confirm base URL and Playwright config**
Open `playwright.config.js`. Confirm chromium is enabled and `baseURL` is set to `http://portfolio.test`. If baseURL is commented out, set:
```js
use: {
baseURL: 'http://portfolio.test',
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
},
```
- [ ] **Step 2: Confirm Playwright + axe dependencies are installed**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npx playwright --version`
Expected: prints a version (e.g. `Version 1.52.x`).
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm ls @axe-core/playwright @playwright/test --depth=0`
Expected: both packages installed.
- [ ] **Step 3: Confirm the projects-portfolio plugin is present**
Run (PowerShell): `Get-ChildItem "C:\Users\ksolo\Herd\portfolio\wp-content\plugins\projects-portfolio" | Select-Object Name`
Expected: shows `projects-portfolio.php`, `README.md`, `includes/`, `templates/`, etc.
- [ ] **Step 4: Probe local site reachability with a 3-second timeout**
Run (PowerShell):
```powershell
try { (Invoke-WebRequest -Uri "http://portfolio.test" -UseBasicParsing -TimeoutSec 3).StatusCode } catch { "unreachable: $($_.Exception.Message)" }
```
Expected: prints `200`. If `unreachable: ...`, stop and ask the user — we cannot run browser tests without the local site.
- [ ] **Step 5: Run the existing Playwright suite as baseline**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npx playwright test tests/site-a11y.spec.js --reporter=list`
Expected: may fail (axe violations expected on current light theme) — that's fine, we capture baseline.
- [ ] **Step 6: Commit no changes (this task is verification only)**
If you modified `playwright.config.js`, run:
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add playwright.config.js
git commit -m "chore: set Playwright baseURL to portfolio.test"
```
---
## Task 2: Define `--font-headings` + dark body surface
**Files:**
- Modify: `styles/base/typography.css` (add `--font-headings`)
- Modify: `styles/base/global.css` (dark body, remove light main rule)
**Goal:** Resolve the unresolved token from `notes/DESIGN.md` §12 and flip the body surface to Carbon Blue dark.
- [ ] **Step 1: Add `--font-headings` to typography.css**
In `styles/base/typography.css`, inside the `@theme { ... }` block (after `--font-sans`), add:
```css
--font-headings: var(--font-sans);
```
- [ ] **Step 2: Darken body + remove light main rule in global.css**
In `styles/base/global.css`, find the existing rule:
```css
main#maincontent {
background-color: var(--color-background);
color: var(--color-text);
margin: 0;
padding: 0 0 4rem;
}
```
Replace with:
```css
main#maincontent {
background-color: transparent;
color: var(--color-on-surface, #e3e1e9);
margin: 0;
padding: 0 0 4rem;
}
```
- [ ] **Step 3: Set dark body in typography.css**
In `styles/base/typography.css`, find the existing `body { ... }` block and replace with:
```css
body {
background-color: #100e0b;
color: var(--color-on-surface, #e3e1e9);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--line-height);
}
```
- [ ] **Step 4: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
Expected: `static/dist/theme.css` rebuilt without errors.
- [ ] **Step 5: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add styles/base/typography.css styles/base/global.css static/dist/theme.css
git commit -m "feat(theme): add --font-headings; flip body to Carbon Blue dark"
```
---
## Task 3: Add Carbon Blue tokens to colors.css + theme.json
**Files:**
- Modify: `styles/base/colors.css` (add Carbon Blue + Material-derived tokens)
- Modify: `theme.json` (add dark palette; fix `styles` preset references)
**Goal:** Expose Carbon Blue / dark-surface tokens through both CSS custom properties and the WordPress block-editor palette. Fix the `background` / `text` preset reference mismatch from `notes/DESIGN.md` §12.
- [ ] **Step 1: Append Carbon Blue tokens to colors.css**
Open `styles/base/colors.css`. Inside the `@theme { ... }` block, append:
```css
/* Carbon Blue / dark surface tokens (Portfolio 2026) */
--color-surface: #121318;
--color-surface-dim: #121318;
--color-surface-bright: #38393f;
--color-surface-container-lowest: #0d0e13;
--color-surface-container-low: #1a1b21;
--color-surface-container: #1e1f25;
--color-surface-container-high: #292a2f;
--color-surface-container-highest:#34343a;
--color-on-surface: #e3e1e9;
--color-on-surface-variant:#c5c5d3;
--color-outline: #8f909d;
--color-outline-variant: #444651;
--color-page-base: #100e0b;
--color-backlight: rgba(96, 165, 250, 0.20);
--color-primary-fixed: #dce1ff;
--color-primary-fixed-dim: #b6c4ff;
--color-on-primary-fixed: #00164e;
--color-on-primary-fixed-variant: #264191;
--color-success: #5ccf91;
--color-warning: #f4e04d;
--color-danger: #cc3b3b;
--color-info: #3d7ab0;
```
(If `--color-success` / `--color-warning` / `--color-danger` / `--color-info` already exist in oklch, REMOVE the existing lines first so the new hex values are the only definitions.)
- [ ] **Step 2: Update theme.json palette**
Open `theme.json`. In the `settings.color.palette` array, ADD these entries (don't remove existing ones):
```json
{
"slug": "theme-surface",
"color": "#121318",
"name": "Theme Surface"
},
{
"slug": "theme-on-surface",
"color": "#e3e1e9",
"name": "Theme On Surface"
},
{
"slug": "theme-outline",
"color": "#8f909d",
"name": "Theme Outline"
},
{
"slug": "theme-outline-variant",
"color": "#444651",
"name": "Theme Outline Variant"
},
{
"slug": "theme-page-base",
"color": "#100e0b",
"name": "Theme Page Base"
}
```
- [ ] **Step 3: Fix theme.json styles preset references**
In `theme.json`, find the top-level `styles` block:
```json
"styles": {
"color": {
"background": "var(--wp--preset--color--background)",
"text": "var(--wp--preset--color--text)"
},
...
}
```
Replace with:
```json
"styles": {
"color": {
"background": "var(--wp--preset--color--theme-bg, #100e0b)",
"text": "var(--wp--preset--color--theme-text, #e3e1e9)"
},
"elements": {
"link": {
"color": {
"text": "var(--wp--preset--color--theme-bodylinks)"
}
}
},
"typography": {
"fontSize": "var(--wp--preset--font-size--base)",
"fontFamily": "var(--wp--preset--font-family--theme-sans)",
"lineHeight": "1.5"
}
}
```
(The `elements.link` is moved up here so it actually applies; previously it was nested under `styles` rather than `styles.elements`, which makes WordPress ignore it.)
- [ ] **Step 4: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 5: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add styles/base/colors.css theme.json static/dist/theme.css
git commit -m "feat(tokens): add Carbon Blue dark-surface tokens; fix theme.json presets"
```
---
## Task 4: Carbon Blue atmospheric partial
**Files:**
- Create: `views/partials/aura-bg.php`
**Goal:** Provide a reusable partial that renders the Carbon Blue atmospheric field per `notes/Background Gradient.md`. Used by `front-page.php` and `archive-projects.php` only.
- [ ] **Step 1: Write the partial**
Create `views/partials/aura-bg.php`:
```php
<?php
/**
* Aura Background Partial — Carbon Blue
*
* Renders three decorative layers (linear gradient, radial backlight, SVG
* grain) over a transparent container. The body carries `background-color:
* #100e0b`; do NOT set a background on this container or the blend modes
* will composite against the container instead of the page.
*
* All layers are aria-hidden and ignore pointer events. Content must sit
* inside a wrapper with `position: relative; z-index: 1`.
*
* @package KsPortfolio
*/
namespace KsPortfolio;
$blur = isset( $blur ) && 'desktop' === $blur ? 'aura-layer-2--desktop' : 'aura-layer-2--mobile';
?>
<div class="aura-bg" aria-hidden="true">
<div class="aura-layer-1"></div>
<div class="aura-layer-2 <?php echo esc_attr( $blur ); ?>"></div>
<div class="aura-grain">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<filter id="aura-grain-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="4" stitchTiles="stitch" />
<feColorMatrix type="matrix"
values="0.181 0.608 0.061 0 0.075
0.181 0.608 0.061 0 0.075
0.181 0.608 0.061 0 0.075
0 0 0 1 0" />
</filter>
<rect width="100%" height="100%" filter="url(#aura-grain-filter)" />
</svg>
</div>
</div>
```
- [ ] **Step 2: Add CSS for the partial**
Append to `styles/base/global.css` (or create `styles/components/aura.css` and `@import` it from `styles/theme.css`):
```css
/* Carbon Blue atmospheric field (decorative — content must sit in z-index: 1) */
.aura-bg {
position: relative;
overflow: hidden;
min-height: 100vh;
isolation: isolate;
}
.aura-layer-1 {
position: absolute;
inset: 0;
background: linear-gradient(138deg, #05070c 0%, #0f172a 40%, #172554 70%, #1e3a8a 100%);
mix-blend-mode: normal;
pointer-events: none;
transform: translateZ(0);
will-change: transform;
}
.aura-layer-2 {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 45% 50% at 72% 40%, rgba(96, 165, 250, 0.20) 0%, transparent 68%);
mix-blend-mode: screen;
pointer-events: none;
transform: translateZ(0);
will-change: transform;
}
.aura-layer-2--mobile { filter: blur(105px); }
.aura-layer-2--desktop { filter: blur(151px); }
.aura-grain {
position: absolute;
inset: 0;
mix-blend-mode: overlay;
opacity: 0.85;
pointer-events: none;
}
.aura-grain svg { display: block; width: 100%; height: 100%; }
```
- [ ] **Step 3: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add views/partials/aura-bg.php styles/base/global.css static/dist/theme.css
git commit -m "feat(aura): add Carbon Blue atmospheric background partial"
```
---
## Task 5: PHP namespace swap — header.php, footer.php, all lib/*.php
**Files:**
- Modify: `functions.php`
- Modify: `header.php`
- Modify: `footer.php`
- Modify: `lib/activation.php`
- Modify: `lib/class-acf.php`
- Modify: `lib/class-breadcrumbs.php`
- Modify: `lib/class-enqueue.php`
- Modify: `lib/class-menuitems.php`
- Modify: `lib/class-resources.php`
- Modify: `lib/extras.php`
- Modify: `lib/helpers.php`
- Modify: `lib/hooks.php`
- Modify: `lib/search-features.php`
- Modify: `lib/show-template.php`
**Goal:** Replace `namespace SoloFrameEvo;` with `namespace KsPortfolio;` and update internal `__NAMESPACE__ . '\\…'` references.
- [ ] **Step 1: Replace namespace declaration in each PHP file**
For each file listed above, find `namespace SoloFrameEvo;` and replace with `namespace KsPortfolio;`.
- [ ] **Step 2: Replace internal namespace references in header.php**
In `header.php`, the only reference is `namespace SoloFrameEvo;` (already handled in step 1). Verify no `__NAMESPACE__ . '\\…'` calls remain.
- [ ] **Step 3: Replace internal namespace references in lib/class-breadcrumbs.php**
In `lib/class-breadcrumbs.php`, find every `__NAMESPACE__ . '\\…'` and replace `'\\KsPortfolio\\…'` (literal). Example: `__NAMESPACE__ . '\\Breadcrumbs'` becomes `'\\KsPortfolio\\Breadcrumbs'`.
- [ ] **Step 4: Replace internal namespace references in lib/hooks.php**
In `lib/hooks.php`, find every `__NAMESPACE__ . '\\…'` and replace with `'\\KsPortfolio\\…'`.
- [ ] **Step 5: Update functions.php to include the new project helper**
In `functions.php`, after the existing `foreach ( glob( __DIR__ . '/lib/*.php' ) ... )`, add the project helper include (we will create the file in Task 6 — guard with `file_exists` for now):
```php
// Project helper (thin wrapper around the projects-portfolio plugin).
$projects_helper = __DIR__ . '/lib/class-projects.php';
if ( file_exists( $projects_helper ) ) {
require_once $projects_helper;
}
```
- [ ] **Step 6: Run PHPCS**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && composer run lint 2>&1 | tail -40`
Expected: lists current violations (we expect namespace-related errors fixed; other violations are expected at this stage).
- [ ] **Step 7: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add functions.php header.php footer.php lib/
git commit -m "refactor: rename namespace SoloFrameEvo to KsPortfolio"
```
---
## Task 6: `lib/class-projects.php` — provider-agnostic helper
**Files:**
- Create: `lib/class-projects.php`
**Goal:** Wrap the `projects-portfolio` plugin's helpers so templates never branch on provider. Provide safe defaults when the plugin is missing or repo data is unavailable.
- [ ] **Step 1: Write the helper**
Create `lib/class-projects.php`:
```php
<?php
/**
* Project helper for the ks-portfolio theme.
*
* Thin wrapper around the projects-portfolio plugin's provider-aware
* helpers. Templates should call these methods instead of branching on
* provider, so the theme stays decoupled from GitHub/Gitea implementation
* details.
*
* Every accessor is safe to call whether the plugin is active or not: when
* the underlying function is missing or repo data is unavailable, defaults
* that match the documented data contract are returned.
*
* @package KsPortfolio
*/
namespace KsPortfolio;
class Projects {
/**
* Whether the projects-portfolio plugin is active.
*
* @return bool
*/
public static function is_plugin_active(): bool {
return function_exists( 'projects_portfolio_get_provider' );
}
/**
* Provider label for a project (e.g. 'GitHub', 'Gitea', or 'Unknown').
*
* @param int $post_id Project post ID.
* @return string
*/
public static function get_provider_label( int $post_id ): string {
if ( ! self::is_plugin_active() ) {
return 'Unknown';
}
$provider = projects_portfolio_get_provider( $post_id );
return method_exists( $provider, 'get_label' ) ? (string) $provider->get_label() : 'Unknown';
}
/**
* Release ZIP URL (or empty string).
*
* @param int $post_id Project post ID.
* @return string
*/
public static function get_release_url( int $post_id ): string {
if ( ! self::is_plugin_active() ) {
return '';
}
return (string) projects_portfolio_get_release_url( $post_id );
}
/**
* Public repository browse URL (or empty string).
*
* @param int $post_id Project post ID.
* @return string
*/
public static function get_repo_browse_url( int $post_id ): string {
if ( ! self::is_plugin_active() ) {
return '';
}
$provider = projects_portfolio_get_provider( $post_id );
return method_exists( $provider, 'get_repo_browse_url' ) ? (string) $provider->get_repo_browse_url() : '';
}
/**
* Normalized repository data array (or null).
*
* @param int $post_id Project post ID.
* @return array<string,mixed>|null
*/
public static function get_repo_data( int $post_id ) {
if ( ! self::is_plugin_active() ) {
return null;
}
$data = projects_portfolio_get_repo_data( $post_id );
return is_array( $data ) ? $data : null;
}
/**
* Owner data (avatar/login/html_url) or null.
*
* @param int $post_id Project post ID.
* @return array<string,mixed>|null
*/
public static function get_owner_data( int $post_id ) {
$data = self::get_repo_data( $post_id );
if ( ! $data || empty( $data['owner']['login'] ) ) {
return null;
}
return $data['owner'];
}
/**
* Latest release tag, or 'Unknown'.
*
* @param int $post_id Project post ID.
* @return string
*/
public static function get_latest_version( int $post_id ): string {
if ( ! self::is_plugin_active() ) {
return 'Unknown';
}
$provider = projects_portfolio_get_provider( $post_id );
return method_exists( $provider, 'get_latest_version' ) ? (string) $provider->get_latest_version() : 'Unknown';
}
/**
* Local download endpoint URL for the project.
*
* @param int $post_id Project post ID.
* @return string
*/
public static function get_download_url( int $post_id ): string {
return home_url( '/download/' . $post_id . '/' );
}
/**
* Render a stat-card-safe integer with optional formatting.
*
* @param mixed $value Raw repo stat value.
* @return string Display-safe string ('1.2k', '12,345', etc.) or '—' when empty.
*/
public static function format_stat( $value ): string {
if ( null === $value || '' === $value ) {
return '—';
}
$value = (int) $value;
if ( $value >= 10000 ) {
return number_format( $value / 1000, 1 ) . 'k';
}
return number_format( $value );
}
}
```
- [ ] **Step 2: Run PHPCS**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && composer run lint 2>&1 | tail -30`
Expected: no new violations introduced by this file.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add lib/class-projects.php
git commit -m "feat(projects): add provider-agnostic helper for templates"
```
---
## Task 7: Reusable project-card partial
**Files:**
- Create: `views/components/project-card.php`
**Goal:** One shared card used by home and archive templates. Provider-agnostic. Empty-state safe.
- [ ] **Step 1: Write the partial**
Create `views/components/project-card.php`:
```php
<?php
/**
* Project Card Component
*
* Renders one project card. Used by front-page (Recent Projects) and
* archive-projects. Expects $post (WP_Post) in scope.
*
* @package KsPortfolio
*/
namespace KsPortfolio;
if ( ! isset( $post ) || ! ( $post instanceof \WP_Post ) ) {
return;
}
$post_id = (int) $post->ID;
$type_label = '';
$type_terms = get_the_terms( $post_id, 'project-type' );
if ( ! empty( $type_terms ) && ! is_wp_error( $type_terms ) ) {
$type_label = strtoupper( $type_terms[0]->name );
}
$release_url = Projects::get_release_url( $post_id );
$repo_url = Projects::get_repo_browse_url( $post_id );
$repo_data = Projects::get_repo_data( $post_id );
$language = is_array( $repo_data ) && ! empty( $repo_data['language'] ) ? (string) $repo_data['language'] : '';
$stars = is_array( $repo_data ) && isset( $repo_data['stargazers_count'] ) ? (int) $repo_data['stargazers_count'] : 0;
$excerpt = has_excerpt( $post_id ) ? get_the_excerpt( $post_id ) : wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '…' );
$card_classes = 'project-card group';
?>
<article class="<?php echo esc_attr( $card_classes ); ?>" aria-labelledby="project-<?php echo esc_attr( (string) $post_id ); ?>-title">
<header class="project-card__head">
<?php if ( $type_label ) : ?>
<span class="project-card__type"><?php echo esc_html( $type_label ); ?></span>
<?php endif; ?>
<span class="project-card__provider" aria-hidden="true">
<?php echo esc_html( Projects::get_provider_label( $post_id ) ); ?>
</span>
</header>
<h3 id="project-<?php echo esc_attr( (string) $post_id ); ?>-title" class="project-card__title">
<a class="project-card__title-link" href="<?php echo esc_url( get_permalink( $post_id ) ); ?>">
<?php echo esc_html( get_the_title( $post_id ) ); ?>
</a>
</h3>
<?php if ( $excerpt ) : ?>
<p class="project-card__excerpt"><?php echo esc_html( $excerpt ); ?></p>
<?php endif; ?>
<footer class="project-card__foot">
<?php if ( $language || $stars > 0 ) : ?>
<div class="project-card__meta">
<?php if ( $language ) : ?>
<span class="project-card__lang"><?php echo esc_html( $language ); ?></span>
<?php endif; ?>
<?php if ( $stars > 0 ) : ?>
<span class="project-card__stars" aria-label="<?php echo esc_attr( $stars . ' stars' ); ?>">★ <?php echo esc_html( (string) $stars ); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="project-card__actions">
<?php if ( $release_url ) : ?>
<a class="project-card__action project-card__action--primary" href="<?php echo esc_url( Projects::get_download_url( $post_id ) ); ?>" rel="nofollow">
<?php echo esc_html__( 'Download', 'ks-portfolio' ); ?>
</a>
<?php endif; ?>
<?php if ( $repo_url ) : ?>
<a class="project-card__action project-card__action--secondary" href="<?php echo esc_url( $repo_url ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html__( 'View Repo', 'ks-portfolio' ); ?>
</a>
<?php endif; ?>
</div>
</footer>
</article>
```
- [ ] **Step 2: Add CSS for the card**
Append to `styles/components/project-card.css` (create new file) and import it from `styles/theme.css`:
```css
/* Project card — Carbon Blue surface */
.project-card {
background: var(--color-surface-container, #1e1f25);
border: 1px solid var(--color-outline-variant, #444651);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 200ms ease, transform 200ms ease;
}
.project-card:hover,
.project-card:focus-within {
border-color: var(--color-primary, #b6c4ff);
}
.project-card:focus-within {
outline: 2px solid var(--color-primary, #b6c4ff);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.project-card { transition: none; }
}
.project-card__head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.project-card__type {
background: rgba(182, 196, 255, 0.12);
color: var(--color-primary, #b6c4ff);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 600;
}
.project-card__provider { color: var(--color-on-surface-variant, #c5c5d3); }
.project-card__title {
font-family: var(--font-sans);
font-weight: 700;
font-size: 1.125rem;
line-height: 1.4;
margin: 0;
color: var(--color-on-surface, #e3e1e9);
}
.project-card__title-link {
color: inherit;
text-decoration: none;
}
.project-card__title-link:hover { text-decoration: underline; }
.project-card__excerpt {
color: var(--color-on-surface-variant, #c5c5d3);
font-size: 0.9375rem;
line-height: 1.6;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card__foot {
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid var(--color-outline-variant, #444651);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.project-card__meta { display: inline-flex; gap: 0.75rem; align-items: center; color: var(--color-on-surface-variant, #c5c5d3); font-size: 0.875rem; }
.project-card__actions { display: inline-flex; gap: 0.5rem; }
.project-card__action {
display: inline-flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
line-height: 1.1;
text-decoration: none;
border: 2px solid transparent;
transition: color 200ms ease, background-color 200ms ease, border-color 200ms ease;
}
.project-card__action--primary {
background: var(--color-primary, #b6c4ff);
color: var(--color-on-primary, #05297a);
border-color: var(--color-primary, #b6c4ff);
}
.project-card__action--primary:hover { background: #dce1ff; border-color: #dce1ff; }
.project-card__action--secondary {
color: var(--color-on-surface, #e3e1e9);
border-color: var(--color-outline, #8f909d);
}
.project-card__action--secondary:hover { border-color: var(--color-primary, #b6c4ff); color: var(--color-primary, #b6c4ff); }
.project-card__action:focus-visible {
outline: 2px solid var(--color-primary, #b6c4ff);
outline-offset: 3px;
}
```
- [ ] **Step 3: Register the import**
In `styles/theme.css`, after `@import "./blocks/index.css";`, add:
```css
@import "./components/project-card.css";
```
(If a `styles/components/index.css` already exists, append `@import "./project-card.css";` to it instead and import the index from theme.css.)
- [ ] **Step 4: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 5: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add views/components/project-card.php styles/components/project-card.css styles/theme.css static/dist/theme.css
git commit -m "feat(projects): add reusable project-card component + styles"
```
---
## Task 8: Project stat-card partial
**Files:**
- Create: `views/components/project-stat-card.php`
**Goal:** A single stat tile (Downloads / Stars / Forks / Issues) used by the sidebar in `single-projects.php`.
- [ ] **Step 1: Write the partial**
Create `views/components/project-stat-card.php`:
```php
<?php
/**
* Project Stat Card Component
*
* Renders one stat tile. Expects $label, $value, and $icon (optional SVG
* markup) in scope. $icon MUST be aria-hidden decorative markup; the label
* is the accessible name.
*
* @package KsPortfolio
*/
namespace KsPortfolio;
if ( ! isset( $label ) || ! isset( $value ) ) {
return;
}
$stat_label = (string) $label;
$stat_value = (string) $value;
?>
<div class="stat-card" role="group" aria-label="<?php echo esc_attr( $stat_label ); ?>">
<?php if ( isset( $icon ) && $icon ) : ?>
<span class="stat-card__icon" aria-hidden="true"><?php echo $icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- icon is hardcoded decorative SVG in callers. ?></span>
<?php endif; ?>
<span class="stat-card__value"><?php echo esc_html( $stat_value ); ?></span>
<span class="stat-card__label"><?php echo esc_html( $stat_label ); ?></span>
</div>
```
- [ ] **Step 2: Add CSS for the stat card**
Append to `styles/components/project-card.css` (or create `styles/components/stat-card.css` and import it):
```css
.stat-card {
background: var(--color-surface-container, #1e1f25);
border: 1px solid var(--color-outline-variant, #444651);
border-radius: 1rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.stat-card__icon { color: var(--color-on-surface-variant, #c5c5d3); }
.stat-card__value {
font-weight: 700;
font-size: 1.5rem;
color: var(--color-on-surface, #e3e1e9);
}
.stat-card__label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
color: var(--color-on-surface-variant, #c5c5d3);
}
```
- [ ] **Step 3: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add views/components/project-stat-card.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(projects): add stat-card partial"
```
---
## Task 9: Project meta row partial
**Files:**
- Create: `views/components/project-meta-row.php`
**Goal:** One row in the Repository Details table (Version, Last Updated, License, Language). Provider-agnostic.
- [ ] **Step 1: Write the partial**
Create `views/components/project-meta-row.php`:
```php
<?php
/**
* Project Meta Row Component
*
* Renders one row of the Repository Details table on the project detail
* page. Expects $label, $value, and $icon (optional decorative SVG) in
* scope.
*
* @package KsPortfolio
*/
namespace KsPortfolio;
if ( ! isset( $label ) || ! isset( $value ) ) {
return;
}
$row_label = (string) $label;
$row_value = (string) $value;
?>
<div class="meta-row">
<span class="meta-row__label">
<?php if ( isset( $icon ) && $icon ) : ?>
<span class="meta-row__icon" aria-hidden="true"><?php echo $icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- icon is hardcoded decorative SVG in callers. ?></span>
<?php endif; ?>
<?php echo esc_html( $row_label ); ?>
</span>
<span class="meta-row__value"><?php echo esc_html( $row_value ); ?></span>
</div>
```
- [ ] **Step 2: Add CSS for the meta row**
Append to `styles/components/project-card.css`:
```css
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-outline-variant, #444651);
font-size: 0.9375rem;
}
.meta-row:last-child { border-bottom: 0; }
.meta-row__label { color: var(--color-on-surface-variant, #c5c5d3); display: inline-flex; align-items: center; gap: 0.5rem; }
.meta-row__value { color: var(--color-on-surface, #e3e1e9); font-weight: 600; }
.meta-row__icon { color: var(--color-on-surface-variant, #c5c5d3); }
```
- [ ] **Step 3: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add views/components/project-meta-row.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(projects): add project meta-row component"
```
---
## Task 10: `archive-projects.php` — project archive template
**Files:**
- Create: `archive-projects.php`
**Goal:** Render the project archive to match `notes/archive-screen.png` — hero + grid of project cards + pagination.
- [ ] **Step 1: Write the template**
Create `archive-projects.php`:
```php
<?php
/**
* Project Archive Template
*
* @package KsPortfolio
*/
namespace KsPortfolio;
get_header();
$hero_intro = get_field( 'archive_intro', (int) get_queried_object_id() );
if ( ! $hero_intro ) {
$hero_intro = __( 'A comprehensive catalog of WordPress plugins, custom themes, and open-source contributions developed for scalable enterprise environments. Focused on modern development practices, performant architectures, and secure integrations.', 'ks-portfolio' );
}
?>
<section class="archive-projects aura-bg">
<div class="aura-bg__content">
<div class="container mx-auto my-section">
<header class="archive-projects__head">
<h1 class="archive-projects__title"><?php echo esc_html__( 'Project Archive', 'ks-portfolio' ); ?></h1>
<p class="archive-projects__intro"><?php echo esc_html( $hero_intro ); ?></p>
</header>
<?php if ( have_posts() ) : ?>
<div class="archive-projects__grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<?php
while ( have_posts() ) :
the_post();
get_template_part( 'views/components/project-card' );
endwhile;
?>
</div>
<div class="archive-projects__pagination">
<?php
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => '&laquo; ' . esc_html__( 'Previous', 'ks-portfolio' ),
'next_text' => esc_html__( 'Next', 'ks-portfolio' ) . ' &raquo;',
)
);
?>
</div>
<?php else : ?>
<div class="archive-projects__empty">
<h2 class="archive-projects__empty-title"><?php echo esc_html__( 'Nothing here yet&hellip;', 'ks-portfolio' ); ?></h2>
<p class="archive-projects__empty-desc"><?php echo esc_html__( 'No published projects found.', 'ks-portfolio' ); ?></p>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php
get_template_part( 'views/partials/aura-bg', null, array( 'blur' => 'desktop' ) );
get_footer();
```
- [ ] **Step 2: Add hero + grid styles**
Append to `styles/components/project-card.css`:
```css
.archive-projects { padding-top: 2rem; padding-bottom: 4rem; }
.archive-projects__head {
padding-left: 1rem;
border-left: 4px solid var(--color-primary, #b6c4ff);
margin-bottom: 2rem;
}
.archive-projects__title {
font-family: var(--font-sans);
font-weight: 700;
font-size: 2.25rem;
line-height: 1.2;
margin: 0 0 0.5rem;
color: var(--color-on-surface, #e3e1e9);
}
.archive-projects__intro {
color: var(--color-on-surface-variant, #c5c5d3);
font-size: 1rem;
line-height: 1.6;
max-width: 70ch;
margin: 0;
}
.archive-projects__empty {
text-align: center;
padding: 4rem 1rem;
color: var(--color-on-surface-variant, #c5c5d3);
}
.archive-projects__empty-title {
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
color: var(--color-on-surface, #e3e1e9);
}
/* Pagination links on dark surface */
.archive-projects__pagination a,
.archive-projects__pagination span {
background: var(--color-surface-container, #1e1f25);
color: var(--color-on-surface, #e3e1e9);
border: 1px solid var(--color-outline-variant, #444651);
border-radius: 4px;
}
```
- [ ] **Step 3: Build the CSS bundle**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add archive-projects.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(projects): add archive-projects template"
```
---
## Task 11: `taxonomy-project-type.php` — taxonomy archive
**Files:**
- Create: `taxonomy-project-type.php`
**Goal:** Same shell as archive-projects but with the taxonomy label as the title.
- [ ] **Step 1: Write the template**
Create `taxonomy-project-type.php`:
```php
<?php
/**
* Project Type Taxonomy Archive Template
*
* @package KsPortfolio
*/
namespace KsPortfolio;
get_header();
$queried = get_queried_object();
$title = $queried && ! is_wp_error( $queried ) ? $queried->name : __( 'Projects', 'ks-portfolio' );
?>
<section class="archive-projects aura-bg">
<div class="aura-bg__content">
<div class="container mx-auto my-section">
<header class="archive-projects__head">
<h1 class="archive-projects__title"><?php echo esc_html( $title ); ?></h1>
<p class="archive-projects__intro">
<a class="archive-projects__back-link" href="<?php echo esc_url( get_post_type_archive_link( 'projects' ) ); ?>">
<?php echo esc_html__( '&larr; All Projects', 'ks-portfolio' ); ?>
</a>
</p>
</header>
<?php if ( have_posts() ) : ?>
<div class="archive-projects__grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<?php
while ( have_posts() ) :
the_post();
get_template_part( 'views/components/project-card' );
endwhile;
?>
</div>
<div class="archive-projects__pagination">
<?php
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => '&laquo; ' . esc_html__( 'Previous', 'ks-portfolio' ),
'next_text' => esc_html__( 'Next', 'ks-portfolio' ) . ' &raquo;',
)
);
?>
</div>
<?php else : ?>
<div class="archive-projects__empty">
<h2 class="archive-projects__empty-title"><?php echo esc_html__( 'Nothing here yet&hellip;', 'ks-portfolio' ); ?></h2>
<p class="archive-projects__empty-desc"><?php echo esc_html__( 'No published projects found in this category.', 'ks-portfolio' ); ?></p>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php
get_template_part( 'views/partials/aura-bg', null, array( 'blur' => 'desktop' ) );
get_footer();
```
- [ ] **Step 2: Add back-link style**
Append to `styles/components/project-card.css`:
```css
.archive-projects__back-link {
color: var(--color-primary, #b6c4ff);
text-decoration: none;
font-weight: 600;
}
.archive-projects__back-link:hover { text-decoration: underline; }
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add taxonomy-project-type.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(projects): add taxonomy-project-type template"
```
---
## Task 12: `single-projects.php` — project detail
**Files:**
- Create: `single-projects.php`
**Goal:** Render the project detail page to match `notes/single-screen.png` — hero with provider + title + version, Download/View Repo CTAs, two-column body, owner, repository details, stat cards.
- [ ] **Step 1: Write the template**
Create `single-projects.php`:
```php
<?php
/**
* Single Project Template
*
* @package KsPortfolio
*/
namespace KsPortfolio;
get_header();
while ( have_posts() ) :
the_post();
$post_id = (int) get_the_ID();
$provider = Projects::get_provider_label( $post_id );
$version = Projects::get_latest_version( $post_id );
$release = Projects::get_release_url( $post_id );
$repo = Projects::get_repo_browse_url( $post_id );
$repo_data = Projects::get_repo_data( $post_id );
$owner = Projects::get_owner_data( $post_id );
$type_terms = get_the_terms( $post_id, 'project-type' );
$type_label = ! empty( $type_terms ) && ! is_wp_error( $type_terms ) ? strtoupper( $type_terms[0]->name ) : '';
$excerpt = has_excerpt( $post_id ) ? get_the_excerpt( $post_id ) : '';
?>
<article class="single-project aura-bg">
<div class="aura-bg__content">
<div class="container mx-auto my-section">
<header class="single-project__hero">
<p class="single-project__meta">
<span class="single-project__provider"><?php echo esc_html( $provider ); ?></span>
<?php if ( $type_label ) : ?>
<span class="single-project__type"><?php echo esc_html( $type_label ); ?></span>
<?php endif; ?>
</p>
<h1 class="single-project__title">
<?php echo esc_html( get_the_title( $post_id ) ); ?>
<?php if ( 'Unknown' !== $version ) : ?>
<span class="single-project__version">v<?php echo esc_html( $version ); ?></span>
<?php endif; ?>
</h1>
<?php if ( $excerpt ) : ?>
<p class="single-project__excerpt"><?php echo esc_html( $excerpt ); ?></p>
<?php endif; ?>
<div class="single-project__actions">
<?php if ( $release ) : ?>
<a class="button button--primary" href="<?php echo esc_url( Projects::get_download_url( $post_id ) ); ?>" rel="nofollow">
<?php echo esc_html__( 'Download', 'ks-portfolio' ); ?>
<?php if ( 'Unknown' !== $version ) : ?>
v<?php echo esc_html( $version ); ?>
<?php endif; ?>
</a>
<?php endif; ?>
<?php if ( $repo ) : ?>
<a class="button button--outline" href="<?php echo esc_url( $repo ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html__( 'View Repo', 'ks-portfolio' ); ?>
</a>
<?php endif; ?>
</div>
</header>
<div class="single-project__layout">
<div class="single-project__body">
<?php if ( has_post_thumbnail( $post_id ) ) : ?>
<figure class="single-project__featured">
<?php echo get_the_post_thumbnail( $post_id, 'large' ); ?>
</figure>
<?php endif; ?>
<section class="single-project__content" aria-label="<?php echo esc_attr__( 'Architecture and Implementation', 'ks-portfolio' ); ?>">
<h2><?php echo esc_html__( 'Architecture & Implementation', 'ks-portfolio' ); ?></h2>
<?php the_content(); ?>
</section>
</div>
<aside class="single-project__sidebar" aria-label="<?php echo esc_attr__( 'Repository details', 'ks-portfolio' ); ?>">
<?php if ( $owner ) : ?>
<section class="single-project__maintainer">
<h3><?php echo esc_html__( 'Maintainer', 'ks-portfolio' ); ?></h3>
<div class="single-project__owner">
<?php if ( ! empty( $owner['avatar_url'] ) ) : ?>
<span class="single-project__owner-avatar" style="background-image: url('<?php echo esc_url( $owner['avatar_url'] ); ?>');" aria-hidden="true"></span>
<?php endif; ?>
<a class="single-project__owner-link" href="<?php echo esc_url( ! empty( $owner['html_url'] ) ? $owner['html_url'] : ( 'https://github.com/' . $owner['login'] ) ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( $owner['login'] ); ?>
</a>
</div>
</section>
<?php endif; ?>
<section class="single-project__details">
<h3><?php echo esc_html__( 'Repository Details', 'ks-portfolio' ); ?></h3>
<?php
$updated_at = is_array( $repo_data ) && ! empty( $repo_data['updated_at'] ) ? human_time_diff( strtotime( $repo_data['updated_at'] ), current_time( 'timestamp' ) ) . ' ' . __( 'ago', 'ks-portfolio' ) : '—';
$license = is_array( $repo_data ) && ! empty( $repo_data['license']['name'] ) ? $repo_data['license']['name'] : __( 'None', 'ks-portfolio' );
$language = is_array( $repo_data ) && ! empty( $repo_data['language'] ) ? $repo_data['language'] : '—';
$rows = array(
array( 'label' => __( 'Version', 'ks-portfolio' ), 'value' => 'Unknown' !== $version ? 'v' . $version : __( 'Unknown', 'ks-portfolio' ) ),
array( 'label' => __( 'Last Updated', 'ks-portfolio' ), 'value' => $updated_at ),
array( 'label' => __( 'License', 'ks-portfolio' ), 'value' => $license ),
array( 'label' => __( 'Language', 'ks-portfolio' ), 'value' => $language ),
);
foreach ( $rows as $row ) :
set_query_var( 'label', $row['label'] );
set_query_var( 'value', $row['value'] );
get_template_part( 'views/components/project-meta-row' );
endforeach;
?>
</section>
<section class="single-project__stats" aria-label="<?php echo esc_attr__( 'Repository statistics', 'ks-portfolio' ); ?>">
<?php
$stats = array(
array( 'label' => __( 'Downloads', 'ks-portfolio' ), 'value' => Projects::format_stat( get_post_meta( $post_id, '_projects_portfolio_download_count', true ) ) ),
array( 'label' => __( 'Stars', 'ks-portfolio' ), 'value' => Projects::format_stat( is_array( $repo_data ) ? ( $repo_data['stargazers_count'] ?? 0 ) : 0 ) ),
array( 'label' => __( 'Forks', 'ks-portfolio' ), 'value' => Projects::format_stat( is_array( $repo_data ) ? ( $repo_data['forks_count'] ?? 0 ) : 0 ) ),
array( 'label' => __( 'Issues', 'ks-portfolio' ), 'value' => Projects::format_stat( is_array( $repo_data ) ? ( $repo_data['open_issues_count'] ?? 0 ) : 0 ) ),
);
foreach ( $stats as $stat ) :
set_query_var( 'label', $stat['label'] );
set_query_var( 'value', $stat['value'] );
get_template_part( 'views/components/project-stat-card' );
endforeach;
?>
</section>
</aside>
</div>
</div>
</div>
</article>
<?php
endwhile;
get_template_part( 'views/partials/aura-bg', null, array( 'blur' => 'desktop' ) );
get_footer();
```
- [ ] **Step 2: Add single-project CSS**
Append to `styles/components/project-card.css`:
```css
.single-project__hero {
background: var(--color-surface-container-low, #1a1b21);
border: 1px solid var(--color-outline-variant, #444651);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
}
.single-project__meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 0.5rem;
color: var(--color-on-surface-variant, #c5c5d3);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.single-project__title {
font-family: var(--font-sans);
font-weight: 700;
font-size: 2.25rem;
line-height: 1.2;
color: var(--color-on-surface, #e3e1e9);
margin: 0 0 0.75rem;
}
.single-project__version { color: var(--color-on-surface-variant, #c5c5d3); font-weight: 400; font-size: 1.5rem; margin-left: 0.5rem; }
.single-project__excerpt {
color: var(--color-on-surface-variant, #c5c5d3);
font-size: 1rem;
line-height: 1.6;
max-width: 70ch;
margin: 0 0 1.5rem;
}
.single-project__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.single-project__layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 1024px) {
.single-project__layout {
grid-template-columns: 2fr 1fr;
gap: 4rem;
}
}
.single-project__body h2,
.single-project__sidebar h3 { color: var(--color-on-surface, #e3e1e9); font-family: var(--font-sans); font-weight: 700; }
.single-project__maintainer,
.single-project__details,
.single-project__stats {
background: var(--color-surface-container, #1e1f25);
border: 1px solid var(--color-outline-variant, #444651);
border-radius: 1rem;
padding: 1.25rem;
margin-bottom: 1rem;
}
.single-project__owner { display: flex; align-items: center; gap: 0.75rem; }
.single-project__owner-avatar {
width: 40px;
height: 40px;
background-size: 40px 40px;
background-repeat: no-repeat;
background-position: center;
border-radius: 0.5rem;
background-color: var(--color-surface-container-high, #292a2f);
}
.single-project__owner-link { color: var(--color-on-surface, #e3e1e9); font-weight: 600; text-decoration: none; }
.single-project__owner-link:hover { color: var(--color-primary, #b6c4ff); }
.single-project__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add single-projects.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(projects): add single-projects detail template"
```
---
## Task 13: `front-page.php` — full home template
**Files:**
- Modify: `front-page.php`
**Goal:** Replace the 8-line stub with a home page matching `notes/home-screen.png`: Carbon Blue hero, Recent Projects grid, footer.
- [ ] **Step 1: Write the template**
Replace `front-page.php` with:
```php
<?php
/**
* Front Page Template — Portfolio 2026 home
*
* @package KsPortfolio
*/
namespace KsPortfolio;
get_header();
$hero_heading = get_field( 'homepage_hero_heading' );
$hero_intro = get_field( 'homepage_hero_intro' );
$hero_cta_1 = get_field( 'homepage_hero_cta_1' ); // array( url, title, target )
$hero_cta_2 = get_field( 'homepage_hero_cta_2' );
?>
<section class="home-hero aura-bg">
<div class="aura-bg__content">
<div class="container mx-auto my-section">
<div class="home-hero__inner">
<?php if ( $hero_heading ) : ?>
<h1 class="home-hero__heading"><?php echo esc_html( $hero_heading ); ?></h1>
<?php endif; ?>
<?php if ( $hero_intro ) : ?>
<p class="home-hero__intro"><?php echo esc_html( $hero_intro ); ?></p>
<?php endif; ?>
<div class="home-hero__actions">
<?php if ( ! empty( $hero_cta_1['url'] ) && ! empty( $hero_cta_1['title'] ) ) : ?>
<a class="button button--primary" href="<?php echo esc_url( $hero_cta_1['url'] ); ?>"<?php echo ! empty( $hero_cta_1['target'] ) ? ' target="_blank" rel="noopener noreferrer"' : ''; ?>>
<?php echo esc_html( $hero_cta_1['title'] ); ?>
</a>
<?php endif; ?>
<?php if ( ! empty( $hero_cta_2['url'] ) && ! empty( $hero_cta_2['title'] ) ) : ?>
<a class="button button--outline" href="<?php echo esc_url( $hero_cta_2['url'] ); ?>"<?php echo ! empty( $hero_cta_2['target'] ) ? ' target="_blank" rel="noopener noreferrer"' : ''; ?>>
<?php echo esc_html( $hero_cta_2['title'] ); ?>
</a>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
<section class="home-recent container mx-auto my-section">
<header class="home-recent__head">
<h2 class="home-recent__title"><?php echo esc_html__( 'Recent Projects', 'ks-portfolio' ); ?></h2>
<a class="home-recent__view-all" href="<?php echo esc_url( get_post_type_archive_link( 'projects' ) ); ?>">
<?php echo esc_html__( 'View All', 'ks-portfolio' ); ?> &rarr;
</a>
</header>
<hr class="home-recent__divider" />
<?php
$recent = new \WP_Query(
array(
'post_type' => 'projects',
'posts_per_page' => 3,
'no_found_rows' => true,
)
);
if ( $recent->have_posts() ) : ?>
<div class="home-recent__grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<?php
while ( $recent->have_posts() ) :
$recent->the_post();
get_template_part( 'views/components/project-card' );
endwhile;
wp_reset_postdata();
?>
</div>
<?php else : ?>
<div class="home-recent__empty">
<h3><?php echo esc_html__( 'Nothing here yet&hellip;', 'ks-portfolio' ); ?></h3>
<p><?php echo esc_html__( 'No published projects found.', 'ks-portfolio' ); ?></p>
</div>
<?php endif; ?>
</section>
<?php
get_template_part( 'views/partials/aura-bg', null, array( 'blur' => 'desktop' ) );
get_footer();
```
- [ ] **Step 2: Add home-hero + home-recent CSS**
Append to `styles/components/project-card.css`:
```css
.home-hero { min-height: 60vh; display: flex; align-items: center; padding: 4rem 0; }
.home-hero__inner { max-width: 50rem; }
.home-hero__heading {
font-family: var(--font-sans);
font-weight: 700;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.1;
color: var(--color-on-surface, #e3e1e9);
margin: 0 0 1rem;
}
.home-hero__intro {
color: var(--color-on-surface-variant, #c5c5d3);
font-size: clamp(1rem, 1.5vw, 1.25rem);
line-height: 1.6;
max-width: 50ch;
margin: 0 0 1.5rem;
}
.home-hero__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.home-recent__head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.home-recent__title {
font-family: var(--font-sans);
font-weight: 700;
font-size: 1.75rem;
color: var(--color-on-surface, #e3e1e9);
margin: 0;
}
.home-recent__view-all {
color: var(--color-primary, #b6c4ff);
font-weight: 600;
text-decoration: none;
}
.home-recent__view-all:hover { text-decoration: underline; }
.home-recent__divider {
border: 0;
border-top: 1px solid var(--color-outline-variant, #444651);
margin: 0 0 1.5rem;
}
.home-recent__empty { text-align: center; padding: 4rem 1rem; color: var(--color-on-surface-variant, #c5c5d3); }
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add front-page.php styles/components/project-card.css static/dist/theme.css
git commit -m "feat(home): rebuild front-page template to match Carbon Blue mockup"
```
---
## Task 14: Header — three-region responsive layout + dark nav
**Files:**
- Modify: `header.php`
- Modify: `styles/navigation/nav-aux.css`
- Modify: `styles/navigation/nav-main-default.css`
**Goal:** Switch from `grid-cols-[83px_auto]` to a three-region header (logo | nav | aux button), recolor nav to Carbon Blue.
- [ ] **Step 1: Update header.php markup**
Replace the existing `.header__nav-main` block in `header.php` with:
```php
<div class="header__nav-main container py-4 items-center grid gap-x-8 gap-y-0 grid-cols-[auto_1fr_auto] grid-rows-[1fr] justify-between">
<a href="<?php echo esc_url( home_url( '/' ) ); ?>" class="site-header__logo block">
<img class="site-header__logo-img" src="<?php echo esc_url( $headerLogo ); ?>" alt="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?> logo" />
</a>
<?php get_template_part( 'views/components/nav-main' ); ?>
<div class="site-header__aux">
<a class="button button--outline" href="<?php echo esc_url( home_url( '/cv/' ) ); ?>">
<?php echo esc_html__( 'Download CV', 'ks-portfolio' ); ?>
</a>
</div>
</div>
```
- [ ] **Step 2: Recolor nav for dark surface**
Append to `styles/navigation/nav-main-default.css`:
```css
.nav-main a,
.nav-aux a {
color: var(--color-on-surface, #e3e1e9);
}
.nav-main a:hover,
.nav-aux a:hover {
color: var(--color-primary, #b6c4ff);
}
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add header.php styles/navigation/ static/dist/theme.css
git commit -m "feat(header): three-region layout + dark nav colors"
```
---
## Task 15: Footer — Carbon Blue surface
**Files:**
- Modify: `footer.php`
**Goal:** Recolor footer links, swap "Footer Area 1/2/3" headings for widget titles, and update copyright to match Carbon Blue palette.
- [ ] **Step 1: Update footer.php**
Find the existing `bg-gray-800 text-white` block on `<footer>` and replace with:
```php
<footer role="contentinfo" class="site-footer">
<div class="container mx-auto py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="site-footer__brand max-w-[40ch]">
<h2 class="site-footer__title">
<a href="<?php echo esc_url( home_url( '/' ) ); ?>">
<?php if ( $footerLogo ) { ?>
<img src="<?php echo esc_url( $footerLogo ); ?>" alt="<?php echo esc_attr( get_bloginfo( 'name' ) ); ?>" />
<?php } else {
echo esc_html( get_bloginfo( 'name' ) );
} ?>
</a>
</h2>
<?php if ( $footerDesc ) : ?>
<p class="site-footer__desc"><?php echo wp_kses_post( $footerDesc ); ?></p>
<?php endif; ?>
</div>
<?php for ( $i = 1; $i <= 3; $i++ ) :
$area_id = 'footer-' . $i;
if ( ! is_active_sidebar( $area_id ) ) {
continue;
}
?>
<div class="site-footer__area">
<?php dynamic_sidebar( $area_id ); ?>
</div>
<?php endfor; ?>
<?php if ( $footerNav ) : ?>
<nav class="site-footer__nav" aria-label="<?php echo esc_attr__( 'Footer navigation', 'ks-portfolio' ); ?>">
<h3><?php echo esc_html__( 'Navigation', 'ks-portfolio' ); ?></h3>
<ul>
<?php foreach ( $footerNav as $item ) : ?>
<li>
<a href="<?php echo esc_url( $item->url ); ?>">
<?php echo esc_html( $item->title ); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
</div>
</div>
<div class="site-footer__copyright">
<?php echo wp_kses_post( $copyright ); ?>
</div>
</footer>
```
- [ ] **Step 2: Add footer CSS**
Append to `styles/navigation/nav-footer.css` (or create `styles/components/footer.css` and import from `styles/theme.css`):
```css
.site-footer {
background: var(--color-surface, #121318);
color: var(--color-on-surface, #e3e1e9);
border-top: 1px solid var(--color-outline-variant, #444651);
}
.site-footer__title a { color: var(--color-on-surface, #e3e1e9); text-decoration: none; }
.site-footer__desc { color: var(--color-on-surface-variant, #c5c5d3); }
.site-footer__area h3,
.site-footer__nav h3 {
color: var(--color-on-surface, #e3e1e9);
font-family: var(--font-sans);
font-weight: 700;
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-outline-variant, #444651);
}
.site-footer a { color: var(--color-primary, #b6c4ff); }
.site-footer a:hover { text-decoration: underline; }
.site-footer__copyright {
background: var(--color-surface-container-lowest, #0d0e13);
color: var(--color-on-surface-variant, #c5c5d3);
text-align: center;
padding: 0.75rem;
font-size: 0.875rem;
}
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add footer.php styles/ static/dist/theme.css
git commit -m "feat(footer): Carbon Blue surface + cleaner widget rendering"
```
---
## Task 16: Other templates — namespace swap + dark surface + remove picsum
**Files:**
- Modify: `index.php`
- Modify: `single.php`
- Modify: `page.php`
- Modify: `search.php`
- Modify: `404.php`
- Modify: `views/partials/page-hero.php`
**Goal:** Apply namespace swap, remove `picsum.photos` fallbacks, ensure empty-state copy is the documented strings.
- [ ] **Step 1: Namespace swap + picsum removal in index.php**
In `index.php`:
1. Replace `namespace SoloFrameEvo;` with `namespace KsPortfolio;`.
2. Find the line:
```php
$postImg = $featImg ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();
```
Replace with:
```php
$postImg = $featImg;
```
3. Find the `<img>` tag and wrap the whole `<figure>` in:
```php
<?php if ( has_post_thumbnail() ) : ?>
<figure>...existing markup...</figure>
<?php endif; ?>
```
(Don't render an empty figure.)
- [ ] **Step 2: Namespace swap + picsum removal in search.php**
Same edits as Step 1.
- [ ] **Step 3: Namespace swap in single.php, page.php, 404.php, page-hero.php**
In each file, replace `namespace SoloFrameEvo;` with `namespace KsPortfolio;`. In `404.php`, also update:
```php
<?php echo esc_html__( 'Search for:', 'label' ); ?>
```
to:
```php
<?php echo esc_html__( 'Search for:', 'ks-portfolio' ); ?>
```
(And the same for any other `'label'` text-domain references in `404.php`.)
- [ ] **Step 4: Dark surface for page-hero partial**
In `views/partials/page-hero.php`:
1. Namespace swap.
2. Replace the existing `bg-cover bg-no-repeat mb-12 py-12 lg:py-16 bg-dark text-light overflow-hidden` with:
```php
<section class="page-hero py-12 lg:py-16">
```
3. Replace the `<h1>` color classes `text-light` with `page-hero__title` (let CSS handle it).
- [ ] **Step 5: Add page-hero CSS**
Append to `styles/components/page-hero.css` (create new file and import from `styles/theme.css`):
```css
.page-hero {
background: var(--color-surface-container-low, #1a1b21);
color: var(--color-on-surface, #e3e1e9);
border-bottom: 1px solid var(--color-outline-variant, #444651);
margin-bottom: 2rem;
}
.page-hero__title { color: var(--color-on-surface, #e3e1e9); font-family: var(--font-sans); font-weight: 700; }
.page-hero__intro { color: var(--color-on-surface-variant, #c5c5d3); }
```
- [ ] **Step 6: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add index.php single.php page.php search.php 404.php views/partials/page-hero.php styles/ static/dist/theme.css
git commit -m "feat(templates): namespace swap + dark surface + remove picsum fallbacks"
```
---
## Task 17: Button styles for dark surface
**Files:**
- Modify: `styles/blocks/buttons.css`
**Goal:** Verify button classes render with WCAG-AA contrast against `#100e0b`. Add a `button--outline` variant if missing (used by the home + archive + single-project templates).
- [ ] **Step 1: Inspect existing button styles**
Read `styles/blocks/buttons.css`. Confirm it defines (or can be extended to define) the classes: `button`, `button--primary`, `button--outline`, `button--secondary`, `button--white`, `button--black`. Note what's present.
- [ ] **Step 2: Append dark-surface button styles**
Append to `styles/blocks/buttons.css`:
```css
.button {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
line-height: 1.1;
padding: 0.5rem 2rem;
border-radius: 0.5rem;
border: 3px solid transparent;
text-decoration: none;
transition: color 200ms ease, background-color 200ms ease, border-color 200ms ease;
}
.button:active { transform: scale(0.99); }
@media (prefers-reduced-motion: reduce) { .button:active { transform: none; } }
.button:focus-visible { outline: 2px solid var(--color-primary, #b6c4ff); outline-offset: 3px; }
.button--primary {
background: var(--color-primary, #b6c4ff);
color: var(--color-on-primary, #05297a);
border-color: var(--color-primary, #b6c4ff);
}
.button--primary:hover { background: #dce1ff; border-color: #dce1ff; }
.button--outline {
background: transparent;
color: var(--color-on-surface, #e3e1e9);
border-color: var(--color-outline, #8f909d);
}
.button--outline:hover { border-color: var(--color-primary, #b6c4ff); color: var(--color-primary, #b6c4ff); }
.button--secondary {
background: var(--color-surface-container-high, #292a2f);
color: var(--color-on-surface, #e3e1e9);
border-color: var(--color-outline-variant, #444651);
}
```
- [ ] **Step 3: Build + commit**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npm run build`
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add styles/blocks/buttons.css static/dist/theme.css
git commit -m "feat(buttons): dark-surface button variants with WCAG-AA contrast"
```
---
## Task 18: Playwright + axe specs
**Files:**
- Create: `tests/home.spec.js`
- Create: `tests/archive.spec.js`
- Create: `tests/single.spec.js`
**Goal:** Browser + accessibility tests for the new Carbon Blue surfaces.
- [ ] **Step 1: Write tests/home.spec.js**
Create `tests/home.spec.js`:
```js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.use({ viewport: { width: 1920, height: 1080 } });
test.describe('home', () => {
test('home renders Carbon Blue hero + recent projects + zero axe violations', async ({ page }, testInfo) => {
await page.goto('/');
await page.screenshot({ path: 'test-results/home.png', fullPage: true });
// Hero region must be present.
await expect(page.locator('.home-hero')).toBeVisible();
// Either the grid OR the empty state must be present.
const grid = page.locator('.home-recent__grid');
const empty = page.locator('.home-recent__empty');
await expect(grid.or(empty)).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])
.analyze();
await testInfo.attach('axe', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Write tests/archive.spec.js**
Create `tests/archive.spec.js`:
```js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.use({ viewport: { width: 1920, height: 1080 } });
test.describe('archive-projects', () => {
test('project archive renders grid or empty + zero axe violations', async ({ page }, testInfo) => {
await page.goto('/projects/');
await page.screenshot({ path: 'test-results/archive.png', fullPage: true });
await expect(page.locator('.archive-projects__head h1')).toBeVisible();
const grid = page.locator('.archive-projects__grid');
const empty = page.locator('.archive-projects__empty');
await expect(grid.or(empty)).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])
.analyze();
await testInfo.attach('axe', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 3: Write tests/single.spec.js**
Create `tests/single.spec.js`:
```js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.use({ viewport: { width: 1920, height: 1080 } });
test.describe('single-project', () => {
test('project detail renders hero, sidebar, no axe violations', async ({ page }, testInfo) => {
// Pick the first project; skip the test gracefully if none exist.
const response = await page.goto('/projects/');
expect(response && response.status()).toBeLessThan(400);
const firstLink = page.locator('.archive-projects__grid a.project-card__title-link').first();
if (await firstLink.count() === 0) {
test.skip(true, 'No projects to test detail page');
return;
}
await Promise.all([
page.waitForLoadState('networkidle'),
firstLink.click(),
]);
await page.screenshot({ path: 'test-results/single.png', fullPage: true });
await expect(page.locator('.single-project__title')).toBeVisible();
await expect(page.locator('.single-project__sidebar')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])
.analyze();
await testInfo.attach('axe', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 4: Run all Playwright tests**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && npx playwright test --reporter=list`
Expected: all four specs (`site-a11y`, `home`, `archive`, `single`) pass. If any fail, debug the specific violation, fix in the template/CSS, re-run.
- [ ] **Step 5: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add tests/home.spec.js tests/archive.spec.js tests/single.spec.js
git commit -m "test: add Playwright + axe specs for home, archive, single"
```
---
## Task 19: PHPCS clean run
**Files:**
- All PHP files in theme
**Goal:** `composer run lint` exits 0.
- [ ] **Step 1: Run PHPCS**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && composer run lint 2>&1 | tail -80`
- [ ] **Step 2: Fix violations inline**
For each reported violation, fix the file. Common ones expected: missing `wp_kses_post` on `the_content()` calls already correctly used; trailing whitespace; array alignment; `WordPress.Security.EscapeOutput` — fix by adding the right `esc_*` function.
- [ ] **Step 3: Re-run until clean**
Repeat step 1 until the command exits with no violations (or only the documented exclusions in `.phpcs.xml`).
- [ ] **Step 4: Commit any fixes**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add -A
git commit -m "style: PHPCS clean across theme PHP" --allow-empty
```
---
## Task 20: Static security audit
**Files:**
- Audit only (no file changes unless finding requires them)
**Goal:** Identify any Critical/High security issues. Produce a written report that becomes part of the final testing report.
- [ ] **Step 1: Run PHPCS WordPress.Security ruleset**
Run: `cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio" && ./vendor/bin/phpcs --standard=.phpcs.xml --sniffs=WordPress.Security.EscapeOutput,WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput . 2>&1 | tail -80`
Expected: zero findings (since we already passed PHPCS in Task 19). If there are findings, fix inline.
- [ ] **Step 2: Grep for unsanitized superglobal access**
Run (PowerShell):
```powershell
Select-String -Path "C:\Users\ksolo\Herd\portfolio\wp-content\themes\ks-portfolio" -Include "*.php" -Recurse -Pattern '\$_(GET|POST|REQUEST)\[' | ForEach-Object { $_.Path + ":" + $_.LineNumber + " " + $_.Line }
```
Expected: empty result (theme has no direct superglobal access). Any finding is a security issue — fix by switching to `filter_input` + `sanitize_*` or to WordPress API equivalents.
- [ ] **Step 3: Audit new templates manually**
For each new template (`front-page.php`, `archive-projects.php`, `single-projects.php`, `taxonomy-project-type.php`) and each new partial (`views/partials/aura-bg.php`, `views/components/project-card.php`, `views/components/project-stat-card.php`, `views/components/project-meta-row.php`), check:
- Every dynamic output uses the right `esc_*` for context.
- Every state-changing form has `wp_nonce_field` (none in this theme — none expected).
- External URLs use `esc_url` and add `rel="noopener noreferrer"` when `target="_blank"`.
- `set_query_var` / `get_template_part` data is escaped in the consumer partials.
- [ ] **Step 4: Write the security section of the final report**
Append to `docs/superpowers/specs/2026-08-16-portfolio-2026-testing-report.md` (we'll create the file in Task 22):
```
## Security audit
- PHPCS WordPress.Security: <PASS / count of findings>
- Direct superglobal access (grep): <PASS / count of findings>
- Manual template audit: <PASS / list of findings with severity>
Severity summary: Critical: 0, High: 0, Medium: 0, Low: 0, Info: 0.
```
- [ ] **Step 5: Commit audit notes (if any fixes were made)**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add -A
git commit -m "security: remediate audit findings" --allow-empty
```
---
## Task 21: Replace `screenshot.png` with Carbon Blue capture
**Files:**
- Modify: `screenshot.png`
**Goal:** Drop the Basic WP screenshot and replace it with a Carbon Blue capture.
- [ ] **Step 1: Capture the home page screenshot**
Run the existing Playwright test (it already saves `test-results/home.png`) or run explicitly:
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
npx playwright test tests/home.spec.js --reporter=list
```
- [ ] **Step 2: Copy the capture to theme root**
Run (PowerShell):
```powershell
Copy-Item "C:\Users\ksolo\Herd\portfolio\wp-content\themes\ks-portfolio\test-results\home.png" "C:\Users\ksolo\Herd\portfolio\wp-content\themes\ks-portfolio\screenshot.png" -Force
```
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git rm screenshot.png
git add screenshot.png
git commit -m "chore: replace Basic WP screenshot with Carbon Blue home capture"
```
---
## Task 22: Final testing report
**Files:**
- Create: `docs/superpowers/specs/2026-08-16-portfolio-2026-testing-report.md`
**Goal:** Ship a complete report covering all four test categories (browser, functional, unit, pen-test), issues encountered, and recommendations.
- [ ] **Step 1: Write the report**
Create `docs/superpowers/specs/2026-08-16-portfolio-2026-testing-report.md` with the following sections (replace `<…>` with actual numbers):
```markdown
# Portfolio 2026 — Testing Report
**Date:** 2026-08-16
**Theme:** ks-portfolio (Portfolio 2026)
**Local base URL:** http://portfolio.test
## 1. Scope
Browser (Chromium + axe-core), functional (Playwright), unit (PHPCS + WPCS), static security audit. Firefox/WebKit/mobile and OWASP ZAP were out of scope per user choice.
## 2. Browser + functional (Playwright)
Specs run:
- `tests/site-a11y.spec.js` — homepage, blog index, 404
- `tests/home.spec.js` — Carbon Blue hero + recent projects
- `tests/archive.spec.js` — project archive grid or empty state
- `tests/single.spec.js` — project detail hero + sidebar
Result: <PASS / FAIL — count of passing specs>
Screenshots: `test-results/home.png`, `test-results/archive.png`, `test-results/single.png`.
## 3. Unit / lint (PHPCS)
Command: `composer run lint`
Result: <PASS / FAIL — count of remaining violations>
## 4. Static security audit
<see "Security audit" block from Task 20>
## 5. Issues encountered
<list — severity-ranked>
## 6. Recommendations for future improvement
<list — non-blocking>
```
- [ ] **Step 2: Commit**
```bash
cd "C:/Users/ksolo/Herd/portfolio/wp-content/themes/ks-portfolio"
git add docs/superpowers/specs/2026-08-16-portfolio-2026-testing-report.md
git commit -m "docs: add Portfolio 2026 testing report"
```
---
## Self-Review
**Spec coverage:**
- §3 Identity/naming → Tasks 5, 21, 22. ✓
- §4.1 Body surface → Task 2. ✓
- §4.2 Carbon Blue partial → Task 4. ✓
- §4.3 Tokens → Task 3. ✓
- §5.1 front-page → Task 13. ✓
- §5.2 archive-projects → Task 10. ✓
- §5.3 single-projects → Task 12. ✓
- §5.4 taxonomy-project-type → Task 11. ✓
- §5.5 Other templates → Task 16. ✓
- §6.1 Buttons → Task 17. ✓
- §6.2 Project card → Task 7. ✓
- §6.3 Stat card → Task 8. ✓
- §6.4 Navigation → Task 14. ✓
- §6.5 Header layout → Task 14. ✓
- §7 Accessibility → Tasks 17, 18. ✓
- §8 Security → Tasks 19, 20. ✓
- §9 Testing → Tasks 1, 18, 19, 20, 22. ✓
- §10 File-level → all tasks together. ✓
- §12 Definition of done → Task 22. ✓
**Placeholder scan:** no TBDs / "TODO" / "implement later".
**Type consistency:**
- `Projects::get_*` signatures used in Tasks 7, 8, 9, 10, 11, 12, 13 match the definitions in Task 6.
- `Projects::format_stat` returns `'—'` when empty — used by stat-card in Task 12. ✓
- Card partial names (`project-card`, `project-stat-card`, `project-meta-row`) consistent across Tasks 712. ✓
Plan complete and saved to `docs/superpowers/plans/2026-08-16-portfolio-2026-implementation.md`. Two execution options:
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?