# PRD — jasonwoltje.com Professional Website

**Status:** Active
**Milestone:** 0.0.1 (pre-MVP bootstrap)
**Owner:** Jason Woltje
**Last updated:** 2026-04-13

---

## 1. Purpose

Build Jason Woltje's professional personal website (`jasonwoltje.com`) as a Payload CMS–backed portfolio + writing platform. The site anchors Jason's transition from IT Director to software engineer and showcases technical work, writing, and maker/music interests.

## 2. Goals

| Goal | Success criterion |
|---|---|
| Establish a credible engineer-executive brand | "Technical Editorial" design system from `design-samples/` rendered with high fidelity |
| Serve as portfolio for projects | CMS-authored `projects` collection rendered at `/projects` + `/projects/[slug]` |
| Publish long-form writing | `posts` collection + `/writing` and `/writing/[slug]` routes |
| Let Jason edit content without code | Payload admin at `/admin`, sole admin user |
| Deploy on existing homelab | Portainer stack on `w-docker0` behind Traefik; no new infra dependencies |
| Respect immutable-tag hard rule | Deployments reference `sha-<short>`; never `latest` |

## 3. Non-goals (v0.0.x)

- Multi-author CMS or role-based ACL
- Comments / community features
- E-commerce / subscriptions
- Search (Algolia, Meilisearch) — defer until content volume warrants
- Internationalization
- Native mobile app

## 4. Users

| Role | Scope |
|---|---|
| Public visitor | Read all published content, submit contact form, subscribe to newsletter (once Mautic is live) |
| Admin (Jason) | Full Payload admin: create/edit/publish content, manage media, view contact submissions |

## 5. Content model

### Collections

| Name | Purpose | Key fields |
|---|---|---|
| `users` | Payload auth. Jason only for v0.0.x | email, password, role (admin) |
| `media` | All uploads. Sharp-generated sizes: `thumb` (400), `card` (800), `hero` (1600), `og` (1200×630). `alt` required. | url, alt, credit, sizes |
| `categories` | Project/post taxonomy | name, slug, accent (`primary` / `secondary` / `tertiary` — maps to design tokens) |
| `projects` | Portfolio entries | title, slug, role, category (rel), summary, body (richText), stack (string[]), heroImage, gallery, externalUrl, featured (bool), order, status (draft/published), publishedAt, seo (group) |
| `posts` | Blog / long-form writing | title, slug, excerpt, body (richText + blocks: code, callout, image), category (rel), heroImage, tags (string[]), status, publishedAt, seo (group) |
| `gear` | Music/Making items (decorative only for v0.0.x) | name, type, notes, image, accent |
| `contactSubmissions` | Inbound form entries; admin-read only | name, email, brief, source, submittedAt, ipHash, status (new/replied/spam) |

### Globals

| Name | Fields |
|---|---|
| `home` | heroHeadline (richText), heroSub, statusTerminal (baked at build time), featuredProjects (rel[]), ctas |
| `about` | introBlock, makerMindsetBlock, soundtrackBlock, gearRefs (rel[]), timeline (year/title/note[]) |
| `contact` | availabilityBadge, timezoneLabel, directEmail, socialLinks (label, url, icon[]), newsletterEnabled (bool) |
| `resume` | summary, experience[] (company, role, dates, bullets), skills[], education[], pdfExport (auto-generated) |
| `navigation` | primaryLinks (label, href[]), footerStatusText |
| `seo` | siteTitle, defaultDescription, defaultOgImage, twitterHandle, jsonLdPerson |

## 6. Page routing (Next.js App Router)

| Route | Source | Rendering |
|---|---|---|
| `/` | `globals.home` + featured `projects` | ISR (revalidate 60s, on-demand via Payload `afterChange` hook) |
| `/about` | `globals.about` + `gear` | ISR |
| `/projects` | `projects` where status=published | ISR |
| `/projects/[slug]` | `projects` by slug | SSG + ISR fallback |
| `/writing` | `posts` where status=published | ISR |
| `/writing/[slug]` | `posts` by slug | SSG + ISR fallback |
| `/contact` | `globals.contact` | Static shell, client form |
| `/resume` | `globals.resume` | SSR, HTML |
| `/resume.pdf` | `globals.resume` | Dynamic, server-rendered PDF (`@react-pdf/renderer` or Puppeteer) |
| `/admin/**` | Payload admin | Dynamic, auth-gated |
| `/api/contact` | Custom endpoint | Dynamic (Turnstile verify + honeypot + Payload write + email) |
| `/api/health` | Healthcheck | Dynamic |
| `/sitemap.xml`, `/robots.txt`, `/rss.xml` | Generated | Dynamic w/ cache |

## 7. Design system

Source: `design-samples/stitch_jasonwoltje.com/silicon_ethos/DESIGN.md`

**Ported as-is to production:**

| Token family | Source |
|---|---|
| Colors (M3 tokens) | Inline `tailwind.config` in stitch HTMLs → `tailwind.config.ts` `extend.colors` |
| Typography | Space Grotesk (display/labels) + Inter (body), `next/font/google` self-hosted, CSS vars `--font-headline` / `--font-body` / `--font-label` |
| Icons | `lucide-react` (replaces Material Symbols CDN) |
| Tailwind plugins | `@tailwindcss/forms`, `@tailwindcss/container-queries` |
| Utilities | `.ghost-border` (outline-variant/15), `.glass-card` (backdrop-blur + surface-bright/60), `.neon-cta` (primary→primary-container gradient + cyan glow) |

**Hard design rules (from DESIGN.md):**

- No 1px solid borders at 100% opacity — tonal surfaces only
- No 50/50 splits — 60/40 asymmetric layouts
- Status Terminal on header/footer (`LOC / STATUS / REV`) — baked from build SHA
- Dark mode always-on (`<html class="dark">` permanent for v0.0.x)

**Implementation approach:** fixed Next pages composed of typed React section components (HeroHeadline, ProjectBentoGrid, MakerMindsetCard, AudioSignalPath, StatusTerminal, ContactForm, NewsletterBand, SocialBento). Content comes from Payload globals/collections. No generic drag-and-drop page builder — protects editorial asymmetry.

## 8. Services & integrations

| Service | Purpose | Status |
|---|---|---|
| Cloudflare Turnstile | Contact form CAPTCHA | To integrate (site key + secret) |
| Umami (self-hosted) | Analytics | To deploy separately (stack 2); site loads Umami tracker once URL known |
| Mautic (self-hosted) | Newsletter | NOT YET DEPLOYED — newsletter UI shows "Coming soon" or disabled state until Mautic endpoint exists |
| Resend / SMTP relay | Contact form notifications, Payload email | ASSUMPTION: SMTP credentials from existing homelab relay |
| Cloudflare DNS | `jasonwoltje.com` A record → w-docker0 edge (10.1.1.43) | DNS records to be added |

## 9. Infrastructure

| Element | Value |
|---|---|
| Deploy host | `w-docker0` (10.1.1.45), single-node Docker Swarm |
| Orchestration | Portainer (https://10.1.1.43:9443), endpoint ID 7 |
| Ingress | Edge Traefik on 10.1.1.43 (TLS termination, Let's Encrypt) → per-swarm Traefik on `w-docker0` (HTTP, `entrypoints=web`) |
| External overlay | `traefik-public` (pre-existing) |
| Registry | `git.mosaicstack.dev/jason.woltje/professional-website` (Gitea container packages) |
| CI | Woodpecker CI (ci.mosaicstack.dev), Kaniko builder |
| Image tags | `sha-<8-char>` (always), branch tag (`main`/`develop`), `vX.Y.Z` (on git tag). `latest` exists but is NEVER referenced by compose. |

### Traefik labels (canonical pattern)

Mirrors `mosaic-stack-website/docker-compose.swarm.yml`. Labels live under `deploy.labels` (Swarm). Router uses `entrypoints=web`; TLS handled at edge. Middleware: www→apex 301 redirect.

### Environment variables

| Var | Required | Notes |
|---|---|---|
| `WEB_IMAGE_TAG` | yes | Always `sha-<short>` or `vX.Y.Z`; CI sets, manual deploy updates |
| `PAYLOAD_SECRET` | yes | 32+ char random, Portainer-stored |
| `PAYLOAD_POSTGRES_USER` / `_PASSWORD` / `_DB` | yes | Portainer |
| `DATABASE_URI` | yes | Composed in compose from above |
| `PAYLOAD_PUBLIC_SERVER_URL` | yes | `https://jasonwoltje.com` |
| `NEXT_PUBLIC_SITE_URL` | yes | `https://jasonwoltje.com` |
| `TURNSTILE_SITE_KEY` / `_SECRET_KEY` | yes | Cloudflare Turnstile |
| `NEXT_PUBLIC_UMAMI_SRC` / `_UMAMI_WEBSITE_ID` | optional | Analytics; empty disables tracker |
| `SMTP_*` or `RESEND_API_KEY` | yes | Contact form notifications |
| `NEXT_PUBLIC_BUILD_SHA` / `_BUILD_REV` | yes | Baked at build for Status Terminal |

## 10. CI pipeline (Woodpecker, mirrors `website-web.yml`)

Stages (all depend via `depends_on`):

1. `install` — `pnpm install --frozen-lockfile`
2. `lint` — `pnpm lint`
3. `typecheck` — `pnpm typecheck`
4. `build` — `pnpm build` (validates Next)
5. `security-audit` — `pnpm audit --prod --audit-level=high`
6. `docker-build` — Kaniko → push `sha-<short>` + branch tag + `latest`-on-main
7. `security-trivy` — fail on HIGH/CRITICAL in built image
8. `link-package` — POST Gitea package→repo link
9. `deploy` (Phase 2, not v0.0.1) — Portainer API redeploy
10. `health-check` (Phase 2) — poll `/api/health`

For v0.0.1, ship through step 8. Initial deploy and subsequent redeploys are manual via Portainer UI until the auto-deploy step is wired.

## 11. Acceptance criteria (v0.0.1 → v0.1.0 MVP)

- [ ] Repo exists at `git.mosaicstack.dev/jason.woltje/professional-website` with PRD + TASKS + README + LICENSE committed
- [ ] Payload 3 + Next.js 16 scaffold boots locally (`pnpm dev` → admin accessible)
- [ ] Tailwind config extends design tokens from `DESIGN.md`
- [ ] All collections + globals defined and seeded with placeholder content
- [ ] All routes render without error (even with empty content)
- [ ] Contact form persists to `contactSubmissions` + emails notification
- [ ] Turnstile gating verified in staging
- [ ] Dockerfile builds reproducibly under Kaniko
- [ ] `.woodpecker/web.yml` present; first CI run pushes image to Gitea registry
- [ ] Trivy scan passes (no HIGH/CRITICAL)
- [ ] Compose stack (`docker-compose.swarm.yml`) deploys on `w-docker0` via Portainer
- [ ] Edge Traefik TLS config updated for `jasonwoltje.com`
- [ ] DNS records active at Cloudflare; apex + www resolve
- [ ] Site loads over HTTPS at both `jasonwoltje.com` and `www.jasonwoltje.com` (www redirects)
- [ ] `/admin` loads Payload admin, Jason can log in
- [ ] `/api/health` returns 200
- [ ] Lighthouse: Performance ≥ 85, Accessibility ≥ 95, SEO ≥ 95 on home page

## 12. Assumptions

- **ASSUMPTION:** SMTP relay credentials available from an existing homelab service or a new Resend free-tier key.
- **ASSUMPTION:** Cloudflare Turnstile site/secret keys will be provisioned on `jason@diversecanvas.com`'s CF account.
- **ASSUMPTION:** Umami will be deployed as a separate Portainer stack; if not ready by MVP, tracker loads only when `NEXT_PUBLIC_UMAMI_WEBSITE_ID` is set.
- **ASSUMPTION:** Mautic deployment is out of scope for this repo; newsletter UI is present but wired as "Coming soon" until a Mautic endpoint is provided.
- **ASSUMPTION:** Payload 3 is compatible with Next.js 16 at build time. If incompatibility emerges, fall back to Next.js 15 LTS — flagged as a rollback path, not an approval gate.
- **ASSUMPTION:** PostgreSQL 17 (alpine) for the `postgres` service. Payload supports pg 15+; no concerns expected.
- **ASSUMPTION:** Headshots in `images/` are pre-upload originals. They are imported into the Payload `media` collection as part of content seeding; originals may stay in repo or be removed at Jason's preference post-seed.

## 13. Escalations (require Jason's decision)

| ID | Topic | Impact |
|---|---|---|
| ESC-01 | Edge Traefik TLS config for `jasonwoltje.com` (new domain, not under `*.woltje.com` wildcard). Does Jason apply the config change, or delegate via runbook? | Blocks HTTPS |
| ESC-02 | Mautic deployment timeline — if it slips, newsletter UI remains stub for MVP | Soft; affects contact page polish |
| ESC-03 | SMTP credentials source — reuse existing relay, or provision Resend? | Blocks contact form email notifications |
| ESC-04 | Turnstile keys — Jason to provision at `cloudflare.com/dashboard` and drop values in Portainer env | Blocks spam-resistant contact form |
| ESC-05 | Analytics (Umami) stack — deploy alongside this site or as separate milestone? | Affects tracker wiring |

## 14. Risks

| Risk | Mitigation |
|---|---|
| Next.js 16 + Payload 3 version drift | Pin exact versions; fallback to Next 15 documented |
| Gitea package path normalization (`jason.woltje` dot) | Dry-run Kaniko push early; adjust image path if Gitea normalizes to `jason-woltje` |
| Single-node host loss | Volume backups (pg_dump + media tar) to offsite (B2/R2) — defer to v0.1.x |
| Contact form spam | Turnstile + honeypot + rate-limit on `/api/contact` |
| Design drift from stitch | Section components mirror stitch HTML structure; design review before v0.1.0 cut |

## 15. Out of scope / deferred

- Full page builder (Payload `blocks`) — consider only if writing volume justifies
- MinIO migration — triggered by >20 GB media or multi-replica frontend
- Authentik SSO — single-admin site doesn't need it
- Auto-deploy via Portainer API — Phase 2 after stable manual deploys
- Offsite backups — Phase 2
- Spotify / Last.fm integrations — decorative only per Jason's call

---

**Source of truth for tasks:** see [`docs/TASKS.md`](TASKS.md).
**Design source:** `design-samples/stitch_jasonwoltje.com/` (HTML mockups + `silicon_ethos/DESIGN.md`).
