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

77 KiB
Raw Blame History

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

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):

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:

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:

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

main#maincontent {
  background-color: var(--color-background);
  color: var(--color-text);
  margin: 0;
  padding: 0 0 4rem;
}

Replace with:

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:

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

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

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

"styles": {
  "color": {
    "background": "var(--wp--preset--color--background)",
    "text": "var(--wp--preset--color--text)"
  },
  ...
}

Replace with:

"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
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
/**
 * 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):

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

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

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

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

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

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

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

.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

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

.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

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

.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

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:

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

.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

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"

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:

<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):

.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

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:
    $postImg = $featImg ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();
    
    Replace with:
    $postImg = $featImg;
    
  3. Find the <img> tag and wrap the whole <figure> in:
    <?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 echo esc_html__( 'Search for:', 'label' ); ?>

to:

<?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:
<section class="page-hero py-12 lg:py-16">
  1. 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):

.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

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:

.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

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:

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:

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:

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
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
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):

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)
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:

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):

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
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):

# 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
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?