Initialize Vue 3 portfolio site with Vite, Tailwind CSS, and GSAP. Add Docker support and GitHub Actions for deployment. Include essential project files, configurations, and a Product Requirements Document (PRD).
Deploy static site to GitHub Pages / build (push) Has been cancelled
Deploy static site to GitHub Pages / deploy (push) Has been cancelled

This commit is contained in:
matt committed 2026-04-22 12:20:58 -07:00
commit 8458694866
47 files changed
+4155

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.github
*.md
!README.md
.DS_Store
.env
.env.*
!.env.example
.cursor
PRD
+2
View File
@@ -0,0 +1,2 @@
# Optional: GitHub Pages project site base path (include slashes, e.g. /bio/)
# VITE_BASE=/bio/
+44
View File
@@ -0,0 +1,44 @@
# GitHub Pages: set repository variable or secret VITE_BASE to /repo-name/ for project sites.
# User site (username.github.io) uses default "/" — omit VITE_BASE.
name: Deploy static site to GitHub Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm install
- run: npm run build
env:
VITE_BASE: ${{ vars.VITE_BASE }}
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+16
View File
@@ -0,0 +1,16 @@
node_modules
dist
*.local
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.env
.env.*
!.env.example
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["Vue.volar"],
"unwantedRecommendations": ["octref.vetur"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"[vue]": {
"editor.defaultFormatter": "Vue.volar"
}
}
+25
View File
@@ -0,0 +1,25 @@
# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
# Default `/` is correct for serving at container root. Override only if you reverse-proxy under a subpath.
ARG VITE_BASE=/
ENV VITE_BASE=${VITE_BASE}
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# --- run ---
FROM nginx:1.27-alpine
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ > /dev/null || exit 1
+118
View File
@@ -0,0 +1,118 @@
This **Product Requirements Document (PRD)** outlines a professional, visually dynamic portfolio site designed to showcase a high-level blend of technical leadership and hands-on engineering.
* * *
# PRD: Professional Portfolio & Technical Showcase
## 1\. Project Overview
A centralized, modern web presence that serves as a professional hub. The site must balance the authority of a **Senior Solutions Architect** with the creative technicality of a **Full-Stack Developer**, all while maintaining an aesthetic rooted in the North County San Diego coastline.
## 2\. Visual Identity & Brand
- **Color Palette:** "SoCal Seaside"
- **Primary:** Deep Ocean Blues and Slate (for text and structural elements).
- **Secondary:** Coastal Teals and Sky Blues (for accents and hover states).
- **Highlights:** Warm Gold and Soft Sand (for CTAs, borders, or highlights—mimicking a sunset over the Pacific).
- **Design Language:** Clean, minimalist, and high-contrast. Use glassmorphism (frosted glass) effects for cards to keep the feeling "light" and "airy."
- **Dynamic Elements:** \* Subtle animated background (e.g., gentle CSS waves or a particle system representing "data flow").
- Interactive "Skill Clouds" or a dynamic terminal-style window.
## 3\. Core Features & Functional Requirements
### 3.1. Hero Section
- **Objective:** Immediate impact and role identification.
- **Content:** Name, high-level title (Software Developer | Solutions Architect), and a punchy value proposition.
- **Visual:** A professional headshot or a high-end stylized graphic, paired with a "Quick Links" bar (LinkedIn, GitHub, Email icons).
### 3.2. Technical Skill Matrix
- **Objective:** Display a broad stack at a glance.
- **Categories:** \* **Core Tech:** (e.g., Vue.js, Python, Node.js).
- **Infrastructure:** (e.g., Proxmox, Docker, WireGuard).
- **AI/LLM:** (e.g., Gemini API, Ollama, RAG development).
- **Sales Engineering:** (e.g., Technical Demos, PRD creation, Solution Architecture).
### 3.3. Project & Demo Gallery (The "Home Lab" Showcase)
- **Objective:** Prove technical depth through live examples.
- **Structure:** Grid layout of "Project Cards."
- **Specific Items:**
- **LoreKeeper:** Story-writing assistant (Highlighting Vue/Python/AI).
- **Home Assistant Dashboard:** Custom Vue 3 dashboard.
- **Test Demo Sites:** Links to external subdomains or containers hosting specific proof-of-concepts.
### 3.4. Experience & Education (Digital Resume)
- **Objective:** Provide the "paper" trail.
- **Feature:** An interactive timeline or accordion-style list of roles (notably LambdaTest and previous senior SE positions).
- **Download:** A prominent "Download PDF Resume" button.
### 3.5. Interests (The Human Element)
- **Objective:** Culture fit and personality.
- **Content:** Brief highlights of gardening (Meyer lemons), long-distance running, and passion for storytelling/One Piece.
* * *
## 4\. Technical Stack Recommendations
- **Frontend:** **Vue 3 (Vite)** for speed and familiarity.
- **Styling:** **Tailwind CSS** (perfect for rapid implementation of the SoCal color profile).
- **Animations:** **Framer Motion** (for Vue) or **GSAP** for high-end scroll interactions.
- **Hosting:** Hosted on your home lab (via **Cloudflare Tunnel** or **WireGuard**) to demonstrate infrastructure skills, or **Vercel** for 99.9% uptime.
## 5\. Information Architecture (Site Map)
1. **Home (Single Page Scroll):**
- Hero -> Skills -> Work/Projects -> Experience -> Interests -> Contact.
2. **Navigation:**
- Sticky header with "Jump to" links.
- Dark/Light mode toggle (styled as Sun/Moon).
* * *
## 6\. Success Metrics
- **Performance:** Lighthouse score of 90+ for Performance and SEO.
- **Responsiveness:** Perfect rendering on mobile (for recruiters on the go) and ultrawide monitors (for engineers).
- **Engagement:** Successful click-throughs to the GitHub and live Demo sites.
Would you like to dive deeper into the specific Vue components for that dynamic Home Assistant dashboard link, or perhaps refine the wording for the professional "About" section?
+110
View File
@@ -0,0 +1,110 @@
# Bio portfolio (static)
Single-page portfolio aligned with [PRD/PRDs/Bio Site PRD.md](PRD/PRDs/Bio%20Site%20PRD.md): Vue 3, Vite, Tailwind, GSAP scroll reveals, static `dist/` output for free static hosts.
## Prerequisites
- Node.js 20+ (22 recommended for CI)
## Local development
```bash
npm install
npm run dev
```
Open the URL Vite prints (usually `http://localhost:5173`).
## Production build
```bash
npm run build
npm run preview
```
Artifacts are written to `dist/`. Deploy that folder to **Cloudflare Pages**, **Netlify**, **Vercel**, or **GitHub Pages**.
## Docker (static site in a container)
The app is still a **static** build; the container only runs **nginx** to serve `dist/`. No Node at runtime.
### Build and run
```bash
docker build -t bio-site .
docker run --rm -p 8080:80 bio-site
```
Open `http://localhost:8080`.
### Compose (handy defaults)
```bash
docker compose up -d --build
```
By default this maps **host `8080` → container `80`**. Override the host port:
```bash
HOST_PORT=3000 docker compose up -d --build
```
### `VITE_BASE` inside Docker
- For **http://your-server/`** (site at root): use the default **`/`** (do not set `VITE_BASE`, or set `VITE_BASE=/`).
- For **GitHub Pages–style subpaths** behind a path-based reverse proxy only: build with e.g. `docker build --build-arg VITE_BASE=/bio/ -t bio-site .` or set `VITE_BASE` in `docker-compose.yml` / `.env` next to compose.
Mismatching `VITE_BASE` and where nginx actually serves the app is the most common reason assets return 404.
### Hosting on your LAN (Linux server)
1. **Install Docker** on the server (Docker Engine + Compose plugin, or Docker Desktop on a workstation acting as a host).
2. **Copy the project** (or clone the repo) onto the machine and run `docker compose up -d --build` from the repo root.
3. **Find the server’s LAN IP**, e.g. `ip -br a` or `hostname -I`.
4. From another device on the **same network**, open `http://<LAN-IP>:8080` (or whatever `HOST_PORT` you chose).
5. **Firewall**: allow the host port, e.g. with UFW: `sudo ufw allow 8080/tcp comment 'bio static'` then `sudo ufw reload` (adjust port to match `HOST_PORT`).
6. **Optional**: put **Caddy**, **Traefik**, or **nginx** on the host in front of the container for HTTPS, auth, or a single entrypoint on 443 while the container stays on an internal port.
The container listens on **all interfaces** inside the host’s Docker publish rules; binding to LAN-only is usually done with host firewall rules or by placing a reverse proxy on the edge.
## Customize content
| What | Where |
|------|--------|
| Name, links, email, tagline | `src/content/site.ts` |
| Skills | `src/content/skills.ts` |
| Projects | `src/content/projects.ts` |
| Roles | `src/content/experience.ts` |
| Headshot | Replace `public/avatar.svg` with your image (WebP/AVIF or optimized JPG/PNG under `public/` — update `HeroSection.vue` if you change the filename). |
| Resume PDF | Add `public/resume.pdf` (the Experience section links to it). |
| SEO / social | Update `<title>`, `<meta name="description">`, Open Graph/Twitter tags, and `og:url` in `index.html` to match your real domain. |
## GitHub Pages (project site)
If the site is served from `https://username.github.io/repo-name/`, Vite needs a **base path**:
1. In the repo, add a variable **`VITE_BASE`** (Settings → Secrets and variables → Actions → Variables) with value `/repo-name/` (leading slash, trailing slash optional).
2. Enable GitHub Pages from **GitHub Actions** using the included workflow (Settings → Pages → Build and deployment → Source: GitHub Actions).
3. The workflow [`.github/workflows/pages.yml`](.github/workflows/pages.yml) runs `npm install`, `npm run build` with `VITE_BASE`, and publishes `dist`.
For a **user** site (`username.github.io` with the site at the root of that repo), leave `VITE_BASE` unset so the site uses `/`.
## Environment variable (local)
Create `.env.production` (or `.env`) if you want to test a non-root base locally:
```bash
VITE_BASE=/repo-name/
```
Then `npm run build` and `npm run preview`.
## Lighthouse / performance notes
- Hero image uses `fetchpriority="high"` and explicit `width` / `height`.
- GSAP + ScrollTrigger load **on demand** per section via dynamic `import()` to keep the initial JS smaller.
- Google Fonts load from `index.html`; self-hosting fonts is an optional future optimization.
## License
Private / personal use — adjust as you like.
+12
View File
@@ -0,0 +1,12 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
# Leave default `/` when the site is at the root of the hostname (typical home LAN).
# Set e.g. `/bio/` only if you serve behind a path-prefix reverse proxy.
VITE_BASE: ${VITE_BASE:-/}
ports:
- "${HOST_PORT:-8080}:80"
restart: unless-stopped
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA / static: serve files if present, else index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed assets from Vite
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Software Developer and Solutions Architect — technical leadership, full-stack engineering, and solution architecture."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Portfolio — Developer & Solutions Architect" />
<meta
property="og:description"
content="Software Developer and Solutions Architect — technical leadership, full-stack engineering, and solution architecture."
/>
<meta property="og:url" content="https://example.com" />
<meta property="og:image" content="/og-image.svg" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Portfolio — Developer & Solutions Architect" />
<meta
name="twitter:description"
content="Software Developer and Solutions Architect — technical leadership, full-stack engineering, and solution architecture."
/>
<meta name="twitter:image" content="/og-image.svg" />
<title>Portfolio — Developer & Solutions Architect</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2436
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "bio-site",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"gsap": "^3.12.7",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="480" viewBox="0 0 480 480">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#38bdf8"/>
<stop offset="55%" stop-color="#0d9488"/>
<stop offset="100%" stop-color="#0f2137"/>
</linearGradient>
<linearGradient id="sand" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stop-color="#f4e4c1"/>
<stop offset="100%" stop-color="#d4a574"/>
</linearGradient>
</defs>
<rect width="480" height="480" fill="url(#sky)"/>
<path
fill="url(#sand)"
opacity="0.35"
d="M0 360c80-40 160-20 240 0s160 40 240 20v120H0z"
/>
<circle cx="240" cy="175" r="72" fill="#f4e4c1" opacity="0.9"/>
<path
fill="#f4e4c1"
opacity="0.85"
d="M120 400c40-100 200-100 240 0v80H120z"
/>
<text
x="240"
y="430"
text-anchor="middle"
fill="#0a1628"
font-family="system-ui,sans-serif"
font-size="18"
opacity="0.55"
>
Replace with photo
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g>
<path d="M 0.00 0.00 L 767.93 0.00 C524.54,0.00 511.89,0.09 512.36,1.75 C512.63,2.71 513.14,7.78 513.49,13.00 C514.63,29.98 520.51,69.81 524.71,89.00 C526.09,95.32 528.04,104.34 529.03,109.04 C531.34,119.91 538.66,149.10 540.56,155.00 C541.35,157.48 543.39,164.45 545.08,170.50 C548.13,181.39 559.20,215.64 560.91,219.50 C561.40,220.60 562.82,224.43 564.05,228.00 C567.85,238.99 574.83,256.77 581.57,272.63 C585.11,280.95 588.00,288.00 588.00,288.28 C588.00,288.79 592.65,298.98 599.74,314.00 C601.69,318.12 605.27,325.77 607.71,331.00 C612.72,341.76 619.89,356.00 621.05,357.50 C621.48,358.05 623.72,362.33 626.03,367.00 C629.86,374.73 637.03,387.64 644.28,399.83 C645.81,402.40 647.51,405.40 648.05,406.50 C653.35,417.12 679.53,458.58 695.74,482.00 C715.19,510.11 730.93,531.56 741.78,544.73 C745.92,549.77 748.10,554.03 746.74,554.46 C631.93,590.36 537.63,639.47 447.16,710.49 C440.98,715.33 440.77,715.41 439.31,713.48 C436.26,709.46 420.95,676.79 414.67,660.89 C393.18,606.54 382.99,553.21 383.00,495.11 C383.01,462.19 385.81,433.34 392.09,401.50 C395.11,386.24 401.24,361.33 402.99,357.25 C403.62,355.78 403.61,355.00 402.96,355.00 C399.72,355.00 358.17,377.86 334.00,392.95 C237.17,453.39 156.27,532.73 96.87,625.50 C60.27,682.66 29.80,746.57 7.89,812.12 L 0.00 835.73 Z" fill="rgb(224,242,254)"/>
<path d="M 1024.00 1024.00 L 0.00 1024.00 L 803.50 1023.91 L 792.50 1018.78 C731.41,990.28 670.24,954.04 621.01,917.17 C540.57,856.92 479.36,788.10 439.02,713.07 C439.13,713.23 439.22,713.37 439.31,713.48 C440.77,715.41 440.98,715.33 447.16,710.49 C537.63,639.47 631.93,590.36 746.74,554.46 C747.44,554.24 747.20,553.00 746.24,551.17 C747.68,553.23 748.78,553.99 749.82,553.65 C754.22,552.21 790.09,542.63 802.82,539.49 C833.40,531.95 881.76,523.14 915.00,519.04 C943.85,515.49 992.74,512.00 1013.75,512.00 L 1024.00 512.00 ZM 966.57 986.43 C967.44,987.29 968.25,988.00 968.38,988.00 C968.51,988.00 970.30,985.40 972.36,982.22 C975.85,976.83 983.90,970.00 986.77,970.00 C989.12,970.00 988.08,966.24 985.55,965.60 C981.80,964.66 971.25,954.40 970.49,950.96 C969.65,947.13 966.49,946.92 965.56,950.63 C964.61,954.38 954.41,964.75 950.90,965.52 C948.98,965.94 948.00,966.78 948.00,968.00 C948.00,969.22 948.98,970.06 950.90,970.48 C953.75,971.10 965.00,981.78 965.00,983.85 C965.00,984.41 965.71,985.56 966.57,986.43 ZM 392.63 588.58 C392.11,586.23 391.62,583.87 391.14,581.50 C387.80,565.02 385.49,547.35 384.21,529.11 C385.66,549.31 388.45,569.05 392.63,588.58 ZM 395.71 384.82 C398.57,371.98 401.40,360.96 402.99,357.25 C401.80,360.02 398.59,372.39 395.71,384.82 ZM 430.52 696.54 C426.11,687.55 421.99,678.48 418.16,669.34 C421.80,677.81 426.45,688.03 430.52,696.54 ZM 386.15 438.96 C386.58,435.24 387.04,431.54 387.56,427.87 C387.96,424.92 388.49,421.57 389.11,417.97 C387.96,425.03 386.97,432.00 386.15,438.96 ZM 741.56 544.47 C741.38,544.25 741.20,544.04 741.02,543.82 C741.43,544.31 741.49,544.39 741.56,544.47 Z" fill="rgb(210,176,138)"/>
<path d="M 0.00 835.73 L 7.89 812.12 C29.80,746.57 60.27,682.66 96.87,625.50 C156.27,532.73 237.17,453.39 334.00,392.95 C358.17,377.86 399.72,355.00 402.96,355.00 C403.61,355.00 403.62,355.78 402.99,357.25 C401.40,360.96 398.57,371.98 395.71,384.82 C394.35,390.70 393.06,396.60 392.09,401.50 C390.99,407.07 390.00,412.54 389.11,417.97 C388.49,421.57 387.96,424.92 387.56,427.87 C387.04,431.54 386.58,435.24 386.15,438.96 C384.01,457.23 383.01,475.45 383.00,495.11 C383.00,506.62 383.40,517.93 384.21,529.11 C385.49,547.35 387.80,565.02 391.14,581.50 C391.62,583.87 392.11,586.23 392.63,588.58 C397.81,612.82 405.12,636.76 414.67,660.89 C415.64,663.36 416.83,666.23 418.16,669.34 C418.17,669.36 418.18,669.38 418.19,669.40 C422.01,678.53 426.12,687.57 430.52,696.54 C434.29,704.44 437.56,710.87 439.02,713.07 C479.36,788.10 540.57,856.92 621.01,917.17 C670.24,954.04 731.41,990.28 792.50,1018.78 L 803.50 1023.91 L 0.00 1024.00 L 0.00 835.73 ZM 966.57 986.43 C965.71,985.56 965.00,984.41 965.00,983.85 C965.00,981.78 953.75,971.10 950.90,970.48 C948.98,970.06 948.00,969.22 948.00,968.00 C948.00,966.78 948.98,965.94 950.90,965.52 C954.41,964.75 964.61,954.38 965.56,950.63 C966.49,946.92 969.65,947.13 970.49,950.96 C971.25,954.40 981.80,964.66 985.55,965.60 C988.08,966.24 989.12,970.00 986.77,970.00 C983.90,970.00 975.85,976.83 972.36,982.22 C970.30,985.40 968.51,988.00 968.38,988.00 C968.25,988.00 967.44,987.29 966.57,986.43 Z" fill="rgb(7,89,133)"/>
<path d="M 745.69 550.35 C744.34,548.23 742.40,545.48 741.37,544.24 C731.13,531.82 714.89,509.68 695.74,482.00 C679.53,458.58 653.35,417.12 648.05,406.50 C647.51,405.40 645.81,402.40 644.28,399.83 C637.03,387.64 629.86,374.73 626.03,367.00 C623.72,362.33 621.48,358.05 621.05,357.50 C619.89,356.00 612.72,341.76 607.71,331.00 C605.27,325.77 601.69,318.12 599.74,314.00 C592.65,298.98 588.00,288.79 588.00,288.28 C588.00,288.00 585.11,280.95 581.57,272.63 C574.83,256.77 567.85,238.99 564.05,228.00 C562.82,224.43 561.40,220.60 560.91,219.50 C559.20,215.64 548.13,181.39 545.08,170.50 C543.39,164.45 541.35,157.48 540.56,155.00 C538.66,149.10 531.34,119.91 529.03,109.04 C528.04,104.34 526.09,95.32 524.71,89.00 C520.51,69.81 514.63,29.98 513.49,13.00 C513.14,7.78 512.63,2.71 512.36,1.75 C511.89,0.09 524.54,0.00 767.93,0.00 L 1024.00 0.00 L 1024.00 256.00 L 1024.00 512.00 L 1013.75 512.00 C992.74,512.00 943.85,515.49 915.00,519.04 C881.76,523.14 833.40,531.95 802.82,539.49 C790.09,542.63 754.22,552.21 749.82,553.65 C748.65,554.03 747.41,553.03 745.69,550.35 Z" fill="rgb(252,132,40)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0a1628"/>
<stop offset="100%" stop-color="#152a45"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<text x="80" y="200" fill="#e8d5b7" font-family="system-ui,sans-serif" font-size="28" opacity="0.85">Portfolio</text>
<text x="80" y="310" fill="#f4f4f5" font-family="system-ui,sans-serif" font-size="56" font-weight="700">Developer &amp; Solutions Architect</text>
<text x="80" y="390" fill="#7dd3fc" font-family="system-ui,sans-serif" font-size="26" opacity="0.9">Vue � TypeScript � Infrastructure � AI</text>
<circle cx="1020" cy="200" r="120" fill="#2dd4bf" opacity="0.15"/>
<circle cx="980" cy="480" r="160" fill="#d4a574" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f2137"/>
<path d="M8 22V10l4.5 6L17 10v12" stroke="#2dd4bf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 10h6M23 10v12" stroke="#d4a574" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import SiteHeader from "./components/SiteHeader.vue";
import WaveBackground from "./components/WaveBackground.vue";
import HeroSection from "./components/HeroSection.vue";
import FakeTerminal from "./components/FakeTerminal.vue";
import SkillMatrix from "./components/SkillMatrix.vue";
import ProjectGrid from "./components/ProjectGrid.vue";
import ExperienceSection from "./components/ExperienceSection.vue";
import InterestsSection from "./components/InterestsSection.vue";
import ContactFooter from "./components/ContactFooter.vue";
</script>
<template>
<a
href="#main-content"
class="focus-ring fixed left-4 top-4 z-[100] -translate-y-24 rounded-lg bg-white px-4 py-2 text-sm font-semibold text-ocean-900 shadow-lg transition focus:translate-y-0 dark:bg-ocean-900 dark:text-white"
>
Skip to main content
</a>
<WaveBackground />
<SiteHeader />
<main id="main-content">
<HeroSection />
<FakeTerminal />
<SkillMatrix />
<ProjectGrid />
<ExperienceSection />
<InterestsSection />
<ContactFooter />
</main>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref } from "vue";
import { site } from "../content/site";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
</script>
<template>
<footer
id="contact"
ref="root"
class="border-t border-ocean-800/10 bg-white/40 py-16 dark:border-white/5 dark:bg-ocean-950/40 sm:py-20"
aria-labelledby="contact-heading"
>
<div class="section-inner text-center">
<h2
id="contact-heading"
class="font-display text-3xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-4xl"
>
Let’s connect
</h2>
<p class="mx-auto mt-3 max-w-xl text-slate-mist dark:text-coast-foam/75">
Best for architecture conversations, demo collaborations, or a thoughtful thread on distributed systems
(and/or shonen pacing).
</p>
<div class="mt-8 flex flex-wrap justify-center gap-3">
<a
:href="`mailto:${site.email}`"
class="focus-ring glass-card inline-flex items-center gap-2 px-5 py-3 text-sm font-semibold text-ocean-900 dark:text-white"
>
Email {{ site.email }}
</a>
<a
:href="site.linkedinUrl"
class="focus-ring glass-card inline-flex items-center gap-2 px-5 py-3 text-sm font-semibold text-ocean-900 dark:text-white"
rel="noopener noreferrer"
target="_blank"
>
LinkedIn
</a>
<a
:href="site.githubUrl"
class="focus-ring glass-card inline-flex items-center gap-2 px-5 py-3 text-sm font-semibold text-ocean-900 dark:text-white"
rel="noopener noreferrer"
target="_blank"
>
GitHub
</a>
</div>
<p class="mt-10 text-xs text-slate-mist dark:text-coast-foam/50">
Built with Vue 3, Vite, and Tailwind — static export, no server required.
</p>
</div>
</footer>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from "vue";
import { roles } from "../content/experience";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
</script>
<template>
<section
id="experience"
ref="root"
class="section-inner scroll-mt-24 py-16 sm:py-20"
aria-labelledby="experience-heading"
>
<div class="mx-auto max-w-2xl text-center">
<h2
id="experience-heading"
class="font-display text-3xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-4xl"
>
Experience
</h2>
<p class="mt-3 text-slate-mist dark:text-coast-foam/75">
Replace placeholders in <code class="font-mono text-sm">src/content/experience.ts</code> with your real roles.
</p>
</div>
<div class="relative mx-auto mt-14 max-w-3xl pl-8 sm:pl-10">
<div
class="absolute left-2 top-2 bottom-2 w-px bg-gradient-to-b from-coast-teal via-coast-sky/50 to-sunset-gold/40 sm:left-3"
aria-hidden="true"
/>
<ol class="space-y-10">
<li v-for="r in roles" :key="r.id" class="relative">
<span
class="absolute -left-[1.35rem] top-1 flex h-4 w-4 items-center justify-center rounded-full border-2 border-coast-teal bg-sand-warm dark:border-sunset-gold dark:bg-ocean-900 sm:-left-[1.6rem]"
aria-hidden="true"
>
<span class="h-1.5 w-1.5 rounded-full bg-coast-teal dark:bg-sunset-gold" />
</span>
<article class="glass-card p-5 sm:p-6">
<p class="text-xs font-semibold uppercase tracking-wide text-coast-deep dark:text-coast-teal">
{{ r.period }}
</p>
<h3 class="mt-1 font-display text-xl font-semibold text-ocean-900 dark:text-white">
{{ r.title }}
</h3>
<p class="text-sm font-medium text-slate-mist dark:text-coast-foam/80">
{{ r.company }}
</p>
<ul class="mt-4 space-y-2 text-sm leading-relaxed text-slate-mist dark:text-coast-foam/85">
<li v-for="(h, hi) in r.highlights" :key="hi" class="flex gap-2">
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-sunset-gold/80" aria-hidden="true" />
<span>{{ h }}</span>
</li>
</ul>
</article>
</li>
</ol>
</div>
<div class="mt-12 flex justify-center">
<a
href="/resume.pdf"
class="focus-ring glass-card inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold text-ocean-900 dark:text-white"
download
>
<svg class="h-5 w-5 text-sunset-gold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0l4-4m-4 4L8 11M5 21h14" />
</svg>
Download PDF resume
</a>
</div>
</section>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
const lines = ref<string[]>([]);
const fullScript = [
"$ ssh portfolio.lab",
"Welcome to matt@bio — GNU/Linux",
"$ cat ./values.txt",
"- System Integrity: Architecture must be secure by design and resilient at scale.",
"- Technical Clarity: The best solution is the one that stakeholders actually understand.",
"- Evidence-Based Trust: Demo the value, document the proof, then deploy the vision.",
"- Global Perspective: Code and products should transcend borders and languages.",
"- Continuous Lab-Culture: Innovation starts with curiosity and local experimentation.",
"$ ./stack --highlight",
"vue.js python docker local-llms sales-engineering",
"$ _",
];
let idx = 0;
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
timer = setInterval(() => {
if (idx >= fullScript.length) {
if (timer) clearInterval(timer);
timer = null;
return;
}
lines.value = [...lines.value, fullScript[idx]!];
idx += 1;
}, 520);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<template>
<section ref="root" class="section-inner pb-12" aria-label="Terminal style introduction">
<div
class="glass-card overflow-hidden font-mono text-sm shadow-lg dark:bg-ocean-900/70"
>
<div
class="flex items-center gap-2 border-b border-ocean-800/10 bg-ocean-950/5 px-4 py-2 dark:border-white/10 dark:bg-black/20"
>
<span class="h-2.5 w-2.5 rounded-full bg-red-400/90" />
<span class="h-2.5 w-2.5 rounded-full bg-amber-300/90" />
<span class="h-2.5 w-2.5 rounded-full bg-coast-teal/90" />
<span class="ml-2 text-xs text-slate-mist">portfolio — zsh</span>
</div>
<div class="space-y-1.5 px-4 py-4 text-ocean-900 dark:text-coast-foam/90">
<p v-for="(line, i) in lines" :key="i" class="whitespace-pre-wrap leading-relaxed">
<span v-if="line.startsWith('$')" class="text-coast-deep dark:text-coast-teal">{{ line.slice(0, 2) }}</span
><span v-if="line.startsWith('$')">{{ line.slice(2) }}</span>
<span v-else class="text-slate-mist dark:text-coast-foam/75">{{ line }}</span>
</p>
<p class="flex items-center gap-1 text-coast-deep/70 dark:text-coast-teal/80" aria-hidden="true">
<span class="inline-block h-4 w-2 animate-pulse bg-current" />
</p>
</div>
</div>
</section>
</template>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref } from "vue";
import { site } from "../content/site";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
</script>
<template>
<section
id="hero"
ref="root"
class="section-inner scroll-mt-24 pb-16 pt-12 sm:pb-24 sm:pt-16 lg:pt-20"
aria-labelledby="hero-heading"
>
<div class="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
<div class="space-y-6">
<p
class="inline-flex items-center gap-2 rounded-full border border-coast-deep/20 bg-white/50 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-coast-deep dark:border-coast-teal/30 dark:bg-ocean-900/40 dark:text-coast-teal"
>
Encinitas, San Diego County, California
</p>
<h1
id="hero-heading"
class="font-display text-4xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-5xl lg:text-6xl"
>
{{ site.name }}
</h1>
<p class="text-lg font-medium text-coast-deep dark:text-coast-teal sm:text-xl">
{{ site.titleLine }}
</p>
<p class="max-w-xl text-base leading-relaxed text-slate-mist dark:text-coast-foam/80 sm:text-lg">
{{ site.tagline }}
</p>
<div class="flex flex-wrap items-center gap-3 pt-2">
<a
:href="site.linkedinUrl"
class="focus-ring glass-card inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-ocean-900 transition hover:border-coast-deep/50 dark:text-white"
rel="noopener noreferrer"
target="_blank"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</svg>
LinkedIn
</a>
<a
:href="site.githubUrl"
class="focus-ring glass-card inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-ocean-900 transition hover:border-coast-deep/50 dark:text-white"
rel="noopener noreferrer"
target="_blank"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd"
/>
</svg>
GitHub
</a>
<a
:href="`mailto:${site.email}`"
class="focus-ring glass-card inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-ocean-900 transition hover:border-sunset-gold/60 dark:text-white"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16v12H4z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7l8 6 8-6" />
</svg>
Email
</a>
</div>
</div>
<div class="relative mx-auto w-full max-w-md lg:max-w-none">
<div
class="glass-card relative overflow-hidden p-2 shadow-lg animate-float"
>
<img
src="/me.jpg"
alt="Matthew Edwards"
width="480"
height="480"
class="aspect-square w-full rounded-xl object-cover"
decoding="async"
fetchpriority="high"
/>
<div
class="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-sunset-gold/25"
aria-hidden="true"
/>
</div>
</div>
</div>
</section>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from "vue";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
const interests = [
{
title: "Gardening",
body: "Meyer lemons on the patio — patience, seasonal rhythm, and the occasional battle with snails.",
emoji: "🍋",
},
{
title: "Long-distance running",
body: "Miles for clear thinking. Training plans double as a reminder that compounding beats heroics.",
emoji: "🏃",
},
{
title: "Storytelling",
body: "Narrative structure shows up in demos and docs alike. Current serialized obsession: One Piece.",
emoji: "📖",
},
] as const;
</script>
<template>
<section
id="interests"
ref="root"
class="section-inner scroll-mt-24 py-16 sm:py-20"
aria-labelledby="interests-heading"
>
<div class="mx-auto max-w-2xl text-center">
<h2
id="interests-heading"
class="font-display text-3xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-4xl"
>
Beyond the keyboard
</h2>
<p class="mt-3 text-slate-mist dark:text-coast-foam/75">
The human side — how I recharge and what shows up in how I collaborate.
</p>
</div>
<div class="mt-12 grid gap-6 md:grid-cols-3">
<article
v-for="card in interests"
:key="card.title"
class="glass-card flex flex-col gap-3 p-6"
>
<span class="text-2xl" aria-hidden="true">{{ card.emoji }}</span>
<h3 class="font-display text-lg font-semibold text-ocean-900 dark:text-white">
{{ card.title }}
</h3>
<p class="text-sm leading-relaxed text-slate-mist dark:text-coast-foam/80">
{{ card.body }}
</p>
</article>
</div>
</section>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from "vue";
import { projects } from "../content/projects";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
</script>
<template>
<section
id="projects"
ref="root"
class="section-inner scroll-mt-24 py-16 sm:py-20"
aria-labelledby="projects-heading"
>
<div class="mx-auto max-w-2xl text-center">
<h2
id="projects-heading"
class="font-display text-3xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-4xl"
>
Home lab &amp; demos
</h2>
<p class="mt-3 text-slate-mist dark:text-coast-foam/75">
Selected builds that mirror how I work in the wild: product-minded, infra-aware, AI-curious.
</p>
</div>
<div class="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<article
v-for="p in projects"
:key="p.id"
class="glass-card flex flex-col p-6 transition hover:-translate-y-0.5 hover:border-sunset-gold/35 hover:shadow-lg dark:hover:border-sunset-gold/30"
>
<h3 class="font-display text-xl font-semibold text-ocean-900 dark:text-white">
{{ p.title }}
</h3>
<p class="mt-2 flex-1 text-sm leading-relaxed text-slate-mist dark:text-coast-foam/80">
{{ p.blurb }}
</p>
<ul class="mt-4 flex flex-wrap gap-2">
<li
v-for="t in p.stack"
:key="t"
class="rounded-md bg-ocean-900/5 px-2 py-0.5 text-xs font-medium text-ocean-800 dark:bg-white/5 dark:text-coast-foam/90"
>
{{ t }}
</li>
</ul>
<a
:href="p.href"
class="focus-ring mt-6 inline-flex items-center gap-2 text-sm font-semibold text-coast-deep dark:text-coast-teal"
rel="noopener noreferrer"
target="_blank"
>
{{ p.cta }}
<span aria-hidden="true">→</span>
</a>
</article>
</div>
</section>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import ThemeToggle from "./ThemeToggle.vue";
const links = [
{ href: "#hero", label: "Home" },
{ href: "#skills", label: "Skills" },
{ href: "#projects", label: "Projects" },
{ href: "#experience", label: "Experience" },
{ href: "#interests", label: "Interests" },
{ href: "#contact", label: "Contact" },
] as const;
</script>
<template>
<header
class="sticky top-0 z-50 border-b border-ocean-800/10 bg-sand-warm/80 backdrop-blur-lg dark:border-white/5 dark:bg-ocean-950/75"
>
<div class="section-inner flex h-16 items-center justify-between gap-3">
<a
href="#hero"
class="focus-ring shrink-0 font-display text-lg font-semibold tracking-tight text-ocean-900 dark:text-coast-foam"
>
Portfolio
</a>
<nav
class="flex min-w-0 flex-1 items-center justify-end gap-2 md:justify-center"
aria-label="Primary"
>
<div
class="-mx-1 flex min-w-0 flex-1 gap-1 overflow-x-auto px-1 pb-1 md:flex-initial md:overflow-visible md:pb-0"
>
<a
v-for="l in links"
:key="l.href"
:href="l.href"
class="focus-ring shrink-0 rounded-lg px-2.5 py-2 text-xs font-medium text-slate-mist transition hover:bg-white/60 hover:text-ocean-900 sm:px-3 sm:text-sm dark:hover:bg-white/5 dark:hover:text-white md:px-3"
>
{{ l.label }}
</a>
</div>
</nav>
<div class="flex shrink-0 items-center gap-2">
<ThemeToggle />
</div>
</div>
</header>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from "vue";
import { skillCategories } from "../content/skills";
import { useSectionReveal } from "../composables/useSectionReveal";
const root = ref<HTMLElement | null>(null);
useSectionReveal(root);
</script>
<template>
<section
id="skills"
ref="root"
class="section-inner scroll-mt-24 py-16 sm:py-20"
aria-labelledby="skills-heading"
>
<div class="mx-auto max-w-2xl text-center">
<h2
id="skills-heading"
class="font-display text-3xl font-bold tracking-tight text-ocean-900 dark:text-white sm:text-4xl"
>
Technical skill matrix
</h2>
<p class="mt-3 text-slate-mist dark:text-coast-foam/75">
A quick scan of the stacks I design with, demo, and operate.
</p>
</div>
<div class="mt-12 grid gap-6 sm:grid-cols-2">
<article
v-for="cat in skillCategories"
:key="cat.id"
class="glass-card flex flex-col gap-4 p-6 transition hover:border-coast-deep/30 dark:hover:border-sunset-gold/25"
>
<h3 class="font-display text-lg font-semibold text-ocean-900 dark:text-white">
{{ cat.label }}
</h3>
<ul class="flex flex-wrap gap-2">
<li
v-for="item in cat.items"
:key="item"
class="rounded-full border border-coast-teal/25 bg-coast-teal/10 px-3 py-1 text-xs font-medium text-ocean-800 dark:border-coast-teal/20 dark:bg-coast-deep/25 dark:text-coast-foam"
>
{{ item }}
</li>
</ul>
</article>
</div>
</section>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useTheme } from "../composables/useTheme";
const { isDark, toggleTheme } = useTheme();
</script>
<template>
<button
type="button"
class="focus-ring inline-flex h-10 w-10 items-center justify-center rounded-full border border-ocean-800/20 bg-white/70 text-ocean-900 shadow-sm backdrop-blur-md transition hover:border-coast-deep/40 hover:bg-white dark:border-white/10 dark:bg-ocean-900/60 dark:text-coast-foam dark:hover:border-sunset-gold/40"
:aria-pressed="isDark"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<span class="sr-only">Toggle color theme</span>
<!-- Sun -->
<svg
v-if="isDark"
class="h-5 w-5 text-sunset-amber"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
/>
</svg>
<!-- Moon -->
<svg
v-else
class="h-5 w-5 text-ocean-700"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
</button>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
/** Decorative layers — pure CSS, no canvas. */
</script>
<template>
<div
class="pointer-events-none fixed inset-0 -z-10 overflow-hidden"
aria-hidden="true"
>
<div
class="absolute -left-1/4 top-0 h-[55vh] w-[150%] bg-gradient-radial from-coast-sky/25 via-transparent to-transparent dark:from-coast-deep/20"
/>
<div
class="absolute bottom-0 left-0 right-0 h-[45vh] bg-gradient-to-t from-coast-teal/15 to-transparent dark:from-ocean-800/40"
/>
<svg
class="absolute bottom-0 left-0 w-[200%] animate-wave text-coast-teal/25 dark:text-coast-deep/35"
viewBox="0 0 1440 320"
preserveAspectRatio="none"
>
<path
fill="currentColor"
d="M0,224L80,213.3C160,203,320,181,480,181.3C640,181,800,203,960,213.3C1120,224,1280,224,1360,224L1440,224L1440,320L1360,320C1280,320,1120,320,960,320C800,320,640,320,480,320C320,320,160,320,80,320L0,320Z"
/>
</svg>
<svg
class="absolute -bottom-8 left-[-10%] w-[220%] animate-wave text-coast-sky/20 dark:text-ocean-700/50"
style="animation-delay: -6s"
viewBox="0 0 1440 320"
preserveAspectRatio="none"
>
<path
fill="currentColor"
d="M0,96L60,112C120,128,240,160,360,165.3C480,171,600,149,720,138.7C840,128,960,128,1080,138.7C1200,149,1320,171,1380,181.3L1440,192L1440,320L1380,320C1320,320,1200,320,1080,320C960,320,840,320,720,320C600,320,480,320,360,320C240,320,120,320,60,320L0,320Z"
/>
</svg>
<!-- subtle "data" dots -->
<div
class="absolute inset-0 opacity-[0.35] dark:opacity-25"
style="
background-image: radial-gradient(circle at 20% 30%, rgba(45, 212, 191, 0.12) 0, transparent 40%),
radial-gradient(circle at 80% 20%, rgba(56, 189, 248, 0.1) 0, transparent 35%),
radial-gradient(circle at 70% 80%, rgba(212, 165, 116, 0.08) 0, transparent 45%);
"
/>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
import { type Ref, onMounted, onUnmounted } from "vue";
type RevealTween = {
scrollTrigger?: { kill: () => void } | null;
kill: () => void;
};
/**
* Lazy-loaded GSAP reveal to keep initial bundle smaller.
*/
export function useSectionReveal(target: Ref<HTMLElement | null>) {
let tween: RevealTween | null = null;
onMounted(async () => {
const el = target.value;
if (!el) return;
const [{ gsap }, { default: ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
tween = gsap.from(el, {
opacity: 0,
y: 28,
duration: 0.65,
ease: "power2.out",
scrollTrigger: {
trigger: el,
start: "top 90%",
once: true,
},
}) as RevealTween;
});
onUnmounted(() => {
tween?.scrollTrigger?.kill();
tween?.kill();
tween = null;
});
}
+48
View File
@@ -0,0 +1,48 @@
import { ref } from "vue";
const STORAGE_KEY = "bio-theme";
const isDark = ref(false);
function applyDarkClass(value: boolean) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (value) root.classList.add("dark");
else root.classList.remove("dark");
}
function readInitial(): boolean {
if (typeof window === "undefined") return false;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "dark") return true;
if (stored === "light") return false;
} catch {
/* ignore */
}
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ?? false;
}
/** Call once before mounting the app to avoid a flash of wrong theme. */
export function initTheme() {
const dark = readInitial();
isDark.value = dark;
applyDarkClass(dark);
}
export function useTheme() {
function setTheme(dark: boolean) {
isDark.value = dark;
applyDarkClass(dark);
try {
localStorage.setItem(STORAGE_KEY, dark ? "dark" : "light");
} catch {
/* ignore */
}
}
function toggleTheme() {
setTheme(!isDark.value);
}
return { isDark, setTheme, toggleTheme };
}
+121
View File
@@ -0,0 +1,121 @@
export type Role = {
id: string;
company: string;
title: string;
period: string;
highlights: string[];
};
// export const roles: Role[] = [
// {
// id: "lambdatest",
// company: "LambdaTest",
// title: "Senior Solutions Engineer (example)",
// period: "20XX — Present",
// highlights: [
// "Led technical discovery workshops and architecture reviews with enterprise teams.",
// "Built repeatable demo environments and collateral aligned to buyer journeys.",
// ],
// },
// {
// id: "prior-se",
// company: "Previous employer",
// title: "Senior Sales Engineer",
// period: "20XX — 20XX",
// highlights: [
// "Owned end-to-end technical sales cycles from discovery to security review.",
// "Partnered with product on roadmap feedback from the field.",
// ],
// },
// ];
export const roles: Role[] = [
{
id: "freelance",
company: "Independent Contractor / Architect",
title: "Full-Stack Developer & Project Manager",
period: "2003 — Present",
highlights: [
"Architected and deployed end-to-end SaaS projects and mobile applications, managing the full development lifecycle. [cite: 393]",
"Led technical research into AI/ML models and defined architectural requirements for cloud-native projects. [cite: 394]",
"Managed personnel and budgets for diverse technical projects to ensure on-time delivery. [cite: 395]",
"Utilized AWS and GCP for scalable infrastructure and high-performance React/Python codebases. [cite: 396, 397]"
],
},
{
id: "pubnub",
company: "PubNub Inc",
title: "Senior Solutions Architect",
period: "2021 — 2025",
highlights: [
"Served as the lead technical expert for pre-sales and post-sales engagements with enterprise and startup clients. [cite: 401]",
"Consulted on SDK usage and architectural design patterns for Fintech, MedTech, and IoT industries. [cite: 402]",
"Translated complex customer requirements into actionable product feedback for Engineering and Product teams. [cite: 404]",
"Conducted technical deep-dives and security reviews for C-level executives and engineering teams. [cite: 405]",
"Represented the company at industry trade shows to demonstrate the value of real-time communication PaaS. [cite: 301]"
],
},
{
id: "ebrevia",
company: "eBrevia LLC",
title: "Sales Engineer",
period: "2020 — 2021",
highlights: [
"Secured major client accounts through technical RFP/RFI assessments and compliance documentation. [cite: 411]",
"Addressed complex security and product architecture concerns during SOC 2 compliance reviews. [cite: 412]",
"Bridged communication between clients and product teams to facilitate custom feature implementations. [cite: 413]",
"Delivered compelling demonstrations of machine learning capabilities for legal and technical stakeholders. [cite: 414]"
],
},
{
id: "priceline",
company: "Priceline.com LLC",
title: "Senior Software Engineer / Lead Engineer",
period: "2017 — 2019",
highlights: [
"Directed front-end development for the company's first internationalization (i18n) and localization effort. [cite: 418]",
"Spearheaded the migration of legacy Angular applications to a modern React/Redux framework. [cite: 420]",
"Coordinated server migrations to the cloud and analyzed impacts on global system performance. [cite: 70]",
"Presented strategic findings on internationalization best practices to the CTO and technical leadership. [cite: 421]",
"Mentored junior engineers in maintaining core products and adopting lean development practices. [cite: 71, 72]"
],
},
{
id: "dealflicks",
company: "Dealflicks Inc.",
title: "Software/Web Developer & System Admin",
period: "2013 — 2016",
highlights: [
"Engineered and managed a full-stack website on AWS using Python frameworks Tornado and Django. [cite: 81]",
"Developed a RESTful API in Go and an automated movie showtime scraping feeder. [cite: 83, 84]",
"Built a gift card product catalog and an automated drip email campaign system to drive engagement. [cite: 85, 98]",
"Managed cloud deployments via Fabric and configured secure VPC/VPN staging environments. [cite: 87, 88]",
"Developed a partner web portal using the Java/Scala Play Framework for theater performance metrics. [cite: 94]"
],
},
{
id: "canara",
company: "Canara Inc. (formerly Intellibatt)",
title: "Software & Web Developer",
period: "2012 — 2013",
highlights: [
"Engineered real-time NOC sites for continuous alarm reporting and massive power data stream monitoring. [cite: 106, 107]",
"Built and deployed co-location servers and maintained network firewalls using pfSense. [cite: 101, 102]",
"Developed automated data collection scripts for network mini-computers monitoring power lines. [cite: 109]",
"Maintained large-scale databases and executed complex queries for cross-departmental operations. [cite: 110]",
"Resolved internal IT infrastructure issues across mailboxes, telephony, and computing systems. [cite: 103]"
],
},
{
id: "intrepid",
company: "Intrepid / Anonymizer",
title: "System Engineer / Web Developer",
period: "2005 — 2012",
highlights: [
"Developed and maintained highly secure web applications focused on anonymity and information assurance. [cite: 190, 276]",
"Architected secure network infrastructures utilizing encryption standards such as AES, SSL/TLS, and RSA. [cite: 139, 198]",
"Managed and configured complex routing and firewall environments with pfSense and Juniper JUNOS. [cite: 134, 135, 202]",
"Collaborated on specialized hardware and software monitoring solutions for high-security environments. [cite: 198, 276]"
],
}
];
+35
View File
@@ -0,0 +1,35 @@
export type Project = {
id: string;
title: string;
blurb: string;
stack: string[];
href: string;
cta: string;
};
export const projects: Project[] = [
{
id: "lorekeeper",
title: "LoreKeeper",
blurb: "Story-writing assistant combining Vue, Python, and AI workflows.",
stack: ["Vue", "Python", "AI"],
href: "#",
cta: "View project",
},
{
id: "ha",
title: "Home Assistant Dashboard",
blurb: "Custom Vue 3 dashboard for home automation visibility and control.",
stack: ["Vue 3", "HA API"],
href: "#",
cta: "Live demo",
},
{
id: "demos",
title: "Test Demo Sites",
blurb: "Proof-of-concepts and experiments hosted on subdomains or lab containers.",
stack: ["Containers", "Tunneling"],
href: "#",
cta: "Browse demos",
},
];
+11
View File
@@ -0,0 +1,11 @@
/** Replace with your public URLs and copy. */
export const site = {
name: "Matthew Edwards",
titleLine: "Software Developer | Solutions Architect",
tagline:
"I bridge product vision and reliable systems — from developing full-stack applications and architecting scalable systems, to understanding business needs and offering solutions that meet them.",
email: "matthew.ed@gmail.com",
linkedinUrl: "https://www.linkedin.com/in/matthew-edwards-643ba95/",
githubUrl: "https://github.com/lvbronco/",
ogUrl: "https://beachho.us",
} as const;
+103
View File
@@ -0,0 +1,103 @@
export type SkillCategory = {
id: string;
label: string;
items: string[];
};
// export const skillCategories: SkillCategory[] = [
// {
// id: "core",
// label: "Core Tech",
// items: ["Vue.js", "TypeScript", "Python", "Node.js", "REST APIs"],
// },
// {
// id: "infra",
// label: "Infrastructure",
// items: ["Proxmox", "Docker", "WireGuard", "Linux", "CI/CD"],
// },
// {
// id: "ai",
// label: "AI / LLM",
// items: ["Gemini API", "Ollama", "RAG patterns", "Prompt tooling"],
// },
// {
// id: "sales",
// label: "Sales Engineering",
// items: ["Technical demos", "PRD creation", "Solution architecture", "POC delivery"],
// },
// ];
export const skillCategories: SkillCategory[] = [
{
id: "core",
label: "Core Tech",
items: [
"Python (Async/Pydantic)", // The non-negotiable base for AI engineering in 2026
"React & TypeScript", // Current industry standard for type-safe frontends
"i18n & Global Expansion", // Your unique enterprise differentiator
"Go (Microservices)", // High-performance backend trend
"System Design & Scalability", // Critical for Senior/Lead level assessment
"REST & GraphQL", // Modern API connectivity standards
"Java/Scala (Play Framework)", //
"Performance Optimization", // Crucial for user retention and SEO
"Unit/Integration Testing", //
"Cross-Platform Development", //
"Modern CSS/Tailwind", //
"Legacy Migration Strategy" // A key business-value skill for senior devs
],
},
{
id: "infra",
label: "Infrastructure",
items: [
"Platform Engineering", // 2026 trend moving beyond basic DevOps
"AWS & GCP Cloud Native", // Core demand for cloud-first organizations
"Infrastructure as Code (IaC)", // Managing systems via code (Terraform/Ansible)
"Proxmox & Home Lab", // Your advanced self-hosted edge
"Kubernetes & Docker", // Now a "required" skill for most infra roles
"Zero-Trust Networking", // Modern security trend for distributed teams
"CI/CD & GitOps", // Automated deployment standard
"SRE & High Availability", // Merging ops and software for resilience
"Cloud Security (DevSecOps)", // Integrating security into the dev pipeline
"Monitoring (Splunk/ELK)", //
"Serverless Computing", //
"Hybrid-Cloud Orchestration" //
],
},
{
id: "ai",
label: "AI / LLM",
items: [
"Agentic AI Frameworks", // Using LangChain/LangGraph for autonomous systems
"MCP (Model Context Protocol)", // The 2026 standard for connecting LLMs to tools
"RAG & Data Activation", // Efficient retrieval-augmented generation
"Prompt Engineering", // Refining model behavior
"MLOps & Model Deployment", // Operationalizing AI at scale
"Gemini & OpenAI SDKs", // Leading API platforms
"Local LLM Optimization", // Privacy and cost-focused local execution
"Structured Output Mastery", // Using Pydantic for reliable AI data
"AI Security & Ethics", // Managing bias and safety
"Vector DBs (Pinecone/Chroma)", //
"Function Calling & Tool Use", //
"AI Product Management" // Bridging the gap from model to product
],
},
{
id: "sales",
label: "Sales Engineering",
items: [
"Solution Storytelling", // Translating features into business outcomes
"Technical Discovery", // Deep qualification of customer needs
"SOC 2 & Cybersecurity Reviews", // Critical for closing enterprise deals
"POC Delivery & Management", // Demonstrating value in live environments
"RFP/RFI Technical Strategy", // Winning high-stakes competitive bids
"Consultative Selling", // Acting as a trusted technical advisor
"Stakeholder Management", // Navigating complex political dynamics in accounts
"Cross-Functional Liaison", // Linking Sales, Product, and Engineering
"Public Speaking & Demos", // Trade shows and executive presentations
"API & Integration Strategy", //
"CRM & Pipeline Hygiene", // Strategic use of Salesforce
"Sales Enablement Content" // Creating guides and demos that scale
],
},
];
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
import { initTheme } from "./composables/useTheme";
import "./style.css";
initTheme();
createApp(App).mount("#app");
+35
View File
@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply font-sans antialiased text-slate-ink bg-sand-warm dark:bg-ocean-950 dark:text-coast-foam;
}
::selection {
@apply bg-coast-teal/30 text-ocean-950;
}
.dark ::selection {
@apply bg-sunset-gold/30 text-ocean-950;
}
}
@layer components {
.glass-card {
@apply rounded-2xl border border-white/40 bg-white/55 shadow-glass backdrop-blur-xl dark:border-white/10 dark:bg-ocean-900/50 dark:shadow-glass-dark;
}
.section-inner {
@apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
}
.focus-ring {
@apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coast-deep dark:focus-visible:outline-sunset-gold;
}
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
+100
View File
@@ -0,0 +1,100 @@
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
ocean: {
// Derived from Logo Blue (#075985)
950: "#021526", // Deep midnight base
900: "#032d45", // Darker navy
800: "#054161", // Deep ocean
700: "#064e75", // Slightly darker than logo
600: "#075985", // [LOGO MATCH: Ocean Blue]
500: "#0ea5e9", // Bright accent
},
slate: {
ink: "#0f172a", // Darker, cooler slate to match the new Ocean 950
mist: "#94a3b8", // Lightened for better contrast on dark blues
},
coast: {
teal: "#2dd4bf", // Keep for pop
deep: "#0d9488",
sky: "#38bdf8",
foam: "#E0F2FE", // [LOGO MATCH: Seafoam/Sky]
},
sand: {
DEFAULT: "#D2B48C", // [LOGO MATCH: Sand]
warm: "#f3e1c7", // Lighter tint for backgrounds/cards
},
sunset: {
gold: "#FB8524", // [LOGO MATCH: Sunset Gold]
amber: "#f59e0b",
},
},
// colors: {
// ocean: {
// 950: "#0a1628",
// 900: "#0f2137",
// 800: "#152a45",
// 700: "#1c3554",
// 600: "#244468",
// },
// slate: {
// ink: "#1e293b",
// mist: "#64748b",
// },
// coast: {
// teal: "#2dd4bf",
// deep: "#0d9488",
// sky: "#38bdf8",
// foam: "#7dd3fc",
// },
// sand: {
// DEFAULT: "#e8d5b7",
// warm: "#f4e4c1",
// },
// sunset: {
// gold: "#d4a574",
// amber: "#f59e0b",
// },
// },
fontFamily: {
sans: [
"DM Sans",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"sans-serif",
],
display: ["Outfit", "DM Sans", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
boxShadow: {
glass: "0 8px 32px rgba(15, 33, 55, 0.12)",
"glass-dark": "0 8px 32px rgba(0, 0, 0, 0.35)",
},
animation: {
wave: "wave 18s ease-in-out infinite",
float: "float 6s ease-in-out infinite",
},
keyframes: {
wave: {
"0%, 100%": { transform: "translateX(0) scaleY(1)" },
"50%": { transform: "translateX(-4%) scaleY(1.02)" },
},
float: {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-6px)" },
},
},
},
},
plugins: [],
} satisfies Config;
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
function viteBase(): string {
const raw = process.env.VITE_BASE?.trim();
if (!raw) return "/";
return raw.endsWith("/") ? raw : `${raw}/`;
}
// For GitHub Pages project sites: VITE_BASE=/repo-name/ (trailing slash optional)
export default defineConfig({
plugins: [vue()],
base: viteBase(),
});