# 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-`; 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 (`` 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-` 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-` + 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`).