Compare commits

...

36 Commits

Author SHA1 Message Date
c963e34766 Proper 410/404 pages
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 01:18:38 -03:00
f91531b5fa Even stupider typo... I'm tired.
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:37:45 -03:00
b0146992c2 Silly secret typo
Some checks failed
ci/woodpecker/push/deploy unknown status
ci/woodpecker/push/ci Pipeline failed
2026-02-12 01:36:48 -03:00
7a01cbd2c9 Final notifications setup 2026-02-12 01:35:22 -03:00
fe8cf26a29 Channel ID typo?
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:22:39 -03:00
a519df1016 Stupid typo
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:14:01 -03:00
ceeb76663b Test with posts API and bot
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 01:12:10 -03:00
14edd403eb Let the bot be the notifier and fix for missing svelte kit in unit tests
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 00:48:52 -03:00
9f43bf7879 One last tweak
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 00:35:57 -03:00
2d0a4935a5 Pipeline fixes
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
2026-02-12 00:32:52 -03:00
e4929a4699 Trigger pipelines
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 00:17:00 -03:00
af705efc17 Pipeline fixes
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-11 23:43:14 -03:00
f73b7822a5 Pipeline Notification Updates (swap Discord to Mattermost) 2026-02-11 23:40:39 -03:00
c094cc29ea TJX Logo removal (legal request) (#5) (#6)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
Remove logo at request of TJX Companies

Reviewed-on: #5
Co-authored-by: mifi <badmf@mifi.dev>
Co-committed-by: mifi <badmf@mifi.dev>

Reviewed-on: #6
2026-02-11 00:59:39 +00:00
4cfe2c5da0 Add pipeline notifications
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 12:08:33 -03:00
864c9a735c Minify JS and resolve accessibility issues (back to 100% in Lighthouse) (#4)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
Reviewed-on: #4
Co-authored-by: mifi <badmf@mifi.dev>
Co-committed-by: mifi <badmf@mifi.dev>
2026-02-07 04:41:31 +00:00
11ff3dcff3 Minify JS output
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 00:46:35 -03:00
0f423f0677 Fix for deploy failure
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 19:51:04 -03:00
aaea4169f9 Tweaks to Icons and Such (#3)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
Just some post launch cleanup

Reviewed-on: #3
Co-authored-by: mifi <badmf@mifi.dev>
Co-committed-by: mifi <badmf@mifi.dev>
2026-02-06 22:40:45 +00:00
e76f0f79e8 Update snapshots and .well-known handling (#2)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
This should resolve a few issues...

Reviewed-on: #2
Co-authored-by: mifi <badmf@mifi.dev>
Co-committed-by: mifi <badmf@mifi.dev>
2026-02-02 13:51:57 +00:00
6f2a720479 Svelte fixes and a sitemap
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-02 10:20:44 -03:00
dfa18c8560 A bit of JS to improve the UX slightly... More tests. Everything is kosher now.
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-01 19:01:12 -03:00
3a940e9da1 Up[date snapshots and generation
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-01 15:15:18 -03:00
0266d472d9 Fingers crossed for working CI e2e tests
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-01 14:36:17 -03:00
e34e0e4c7b Internet.nl badge for future use; security.txt
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-01 14:28:26 -03:00
81abdf4539 Enable Playwright e2e tests in dev container and CI pipeline
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-01 13:51:20 -03:00
4bcce26a74 Some icons and basic tweaks for the visuals
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-01 13:37:07 -03:00
52c0fd5e2d Maybe this will stop Lighthouse's complaining
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-01 03:14:46 -03:00
911093f0b6 The Svelte 5 SSG Migration (#1)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
- Migrates the site to Svelte 5
- Still generates a static site with inlined critical path CSS for the ultimate in performance
- Opens up future possibilities for site growth

Reviewed-on: #1
Co-authored-by: mifi <badmf@mifi.dev>
Co-committed-by: mifi <badmf@mifi.dev>
2026-02-01 05:50:41 +00:00
40b770f8b5 Added some navigation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-01 01:56:59 -03:00
d045e56389 GA UTM tracking
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 23:41:09 -03:00
02e4319459 Let's try this...
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 22:27:12 -03:00
288a1b8f7a Switch to media for stylesheet async load
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 22:07:20 -03:00
6cbb947932 Defer that ga script, save the CWV
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 21:59:43 -03:00
c0f812a155 GA changes to allow functionality with strict CSP
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 21:50:34 -03:00
b58077d6e3 Add Google Analytics script
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-31 18:16:41 -03:00
125 changed files with 8828 additions and 3336 deletions

View File

@@ -1,18 +1,11 @@
# Dev container for mifi Ventures static site
# Lightweight: Node for static server (npx serve), no app dependencies
# Dev container: same image as CI e2e (Playwright Noble) so visual snapshots match.
# Snapshots generated in the devcontainer will match CI; no need to run update-snapshots in CI.
FROM mcr.microsoft.com/playwright:v1.58.0-noble
FROM mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
# pnpm for this project (CI uses the same)
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
# Install system deps if needed (none required for static site)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Ensure workspace dir exists (mount point)
RUN mkdir -p /workspaces/mifi-ventures-landing
# Default working directory
WORKDIR /workspaces/mifi-ventures-landing
# npx serve is used at runtime via postStartCommand
# No npm install needed — static site, no package.json
# Default user is root (Playwright image); devcontainer runs as root for e2e.

View File

@@ -1,36 +1,44 @@
{
"name": "mifi Ventures Landing",
"dockerFile": "Dockerfile",
"workspaceFolder": "/workspaces/mifi-ventures-landing",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/mifi-ventures-landing,type=bind",
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "Site",
"onAutoForward": "notify"
}
},
"postStartCommand": "nohup npx -y serve site -l 3000 > /tmp/serve.log 2>&1 & sleep 1",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"files.associations": {
"*.html": "html",
"*.css": "css",
"*.svg": "svg"
"name": "mifi Ventures Landing",
"dockerFile": "Dockerfile",
"workspaceFolder": "/workspaces/mifi-ventures-landing",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/mifi-ventures-landing,type=bind",
"runArgs": ["-u", "root"],
"remoteUser": "root",
"postCreateCommand": "pnpm install",
"forwardPorts": [5173, 4173],
"portsAttributes": {
"5173": {
"label": "Dev (Vite)",
"onAutoForward": "notify"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.watcherExclude": {
"**/node_modules/**": true,
"**/.git/objects/**": true
"4173": {
"label": "Preview (Vite)",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"rvest.vs-code-prettier-eslint",
"yoavbls.pretty-ts-errors",
"svelte.svelte-vscode"
],
"settings": {
"files.associations": {
"*.html": "html",
"*.css": "css",
"*.svg": "svg"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.watcherExclude": {
"**/node_modules/**": true,
"**/.git/objects/**": true
}
}
}
}
}
},
"remoteUser": "node"
}

10
.gitignore vendored
View File

@@ -22,6 +22,16 @@ pnpm-debug.log*
dist/
build/
# SvelteKit / Vite
.svelte-kit/
.vite/
# Test outputs
test-results/
playwright-report/
coverage/
.playwright/
# Environment variables (NEVER commit secrets)
.env
.env.local

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist/
build/
.svelte-kit/
node_modules/
pnpm-lock.yaml

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"printWidth": 90,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -1,106 +0,0 @@
# Woodpecker CI/CD Pipeline for mifi Ventures Landing Site
# Deploys static site to Linode VPS via Docker
# Documentation: https://woodpecker-ci.org/docs
# Trigger: Push to main, tag creation, or manual run from Woodpecker UI
when:
branch: main
event: [push, tag, manual]
steps:
# ============================================
# Stage 1: Build Docker Image
# ============================================
- name: build
image: docker:latest
environment:
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e # Exit on error
- echo "=== Building Docker image ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
docker build \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \
.
- echo "✓ Docker image built successfully"
# ============================================
# Stage 2: Push to Registry
# ============================================
- name: push
image: docker:latest
environment:
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
REGISTRY_USERNAME:
from_secret: registry_username
REGISTRY_PASSWORD:
from_secret: registry_password
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e # Exit on error
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- build
# ============================================
# Stage 3: Trigger Portainer stack redeploy (webhook)
# ============================================
- name: deploy
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on:
- push
# ============================================
# Configuration Reference
# ============================================
#
# Woodpecker has no separate "Variables" UI — use Secrets for everything.
#
# Required Secrets (Repo → Settings → Secrets):
# - registry_username: Your Gitea username (used for docker login)
# - registry_password: Gitea container registry password or token
# - portainer_webhook_url: Portainer stack webhook URL (Redeploy trigger)
#
# REGISTRY_URL and REGISTRY_REPO are set in this file (above).
#
# Portainer: Add stack from "Git repository" with this repo, compose path
# docker-compose.yml. Enable GitOps → Webhook and "Re-pull image".
# Add Gitea registry in Portainer (Settings → Registries) so the host can pull.
#
# If pipeline doesn't run on push: ensure the repo is activated in Woodpecker,
# Gitea has a webhook to Woodpecker for this repo, and your default branch is main.
# If Gitea and Woodpecker run on the same host, Gitea may need [webhook]
# ALLOWED_HOST_LIST=external,loopback in app.ini so webhooks can reach Woodpecker.

151
.woodpecker/ci.yaml Normal file
View File

@@ -0,0 +1,151 @@
# CI workflow: one clone, one workspace — install → lint → build → unit test - e2e test.
# Runs on pull requests, push/tag/manual on main, or manual from any branch.
# Deploy workflow depends on this (ci) and runs only on main.
when:
- event: pull_request
- event: push
branch: main
- event: tag
- event: manual
steps:
- name: install
image: node:20-alpine
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- name: lint
image: node:20-alpine
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run lint
- pnpm run lint:css
depends_on:
- install
- name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- lint
when:
- status: [failure]
- name: unit test
image: node:20-alpine
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm exec svelte-kit sync
- pnpm test
depends_on:
- lint
- name: Send Unit Test Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Unit test failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- unit test
when:
- status: [failure]
- name: build
image: node:20-alpine
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run build
depends_on:
- unit test
- name: Send Test Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Test build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- build
when:
- status: [failure]
- name: e2e test
image: mcr.microsoft.com/playwright:v1.58.0-noble
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run build
- npx serve dist -p 4173 &
- sleep 2
- CI=1 pnpm run test:e2e
depends_on:
- build
- name: Send E2E Test Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] E2E test failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- e2e test
when:
- status: [failure]
- name: Send CI Pipeline Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] CI pipeline success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- install
- lint
- unit test
- build
- e2e test
when:
- status: [success]

188
.woodpecker/deploy.yaml Normal file
View File

@@ -0,0 +1,188 @@
# Deploy workflow: Docker image → push to registry → Portainer webhook.
# Runs on push to main, tag, or manual (only when on main).
# Waits for ci workflow (install → lint → build → test) to succeed first.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
steps:
- name: 'Docker image build'
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Building Docker image ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
docker build \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \
.
- echo "✓ Docker image built successfully"
- name: Send Build Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- 'Docker image build'
when:
- status: [success]
- name: Send Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Docker image build
when:
- status: [failure]
- name: 'Push to registry'
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
REGISTRY_USERNAME:
from_secret: registry_username
REGISTRY_PASSWORD:
from_secret: registry_password
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- 'Docker image build'
- name: Send Push Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- 'Push to registry'
when:
- status: [success]
- name: Send Push Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- 'Push to registry'
when:
- status: [failure]
- name: 'Trigger Portainer stack redeploy'
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on:
- 'Push to registry'
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- 'Trigger Portainer stack redeploy'
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

View File

@@ -0,0 +1,41 @@
# Manual workflow: regenerate e2e Linux snapshot in CI and push it (fallback when not using the devcontainer).
# Prefer updating snapshots in the devcontainer (same image as CI); use this workflow if you don't use the
# devcontainer (e.g. macOS-only) or can't run the update locally. Trigger manually; choose the branch to update.
#
# Required secret: git_push_token — repo push token (e.g. Gitea/Forgejo personal access token).
# Add in Woodpecker → Repo → Settings → Secrets.
when:
event: manual
steps:
- name: update e2e snapshots
image: mcr.microsoft.com/playwright:v1.58.0-noble
commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run build
- npx serve dist -p 4173 &
- sleep 2
- CI=1 pnpm exec playwright test --update-snapshots
- name: commit and push snapshot
image: alpine/git
environment:
GIT_PUSH_TOKEN:
from_secret: git_push_token
commands:
- set -e
- 'git config user.email "ci@woodpecker"'
- 'git config user.name "Woodpecker CI"'
# Push URL with token for write access (Gitea/Forgejo: token as username)
- 'PUSH_URL="https://$${GIT_PUSH_TOKEN}@$${CI_REPO_CLONE_URL#https://}"'
- git remote set-url origin "$PUSH_URL"
- git add tests/visual.spec.ts-snapshots/
- |
if git diff --staged --quiet; then
echo "No snapshot changes to commit."
else
git commit -m "chore: update e2e snapshot from CI [skip ci]"
git push origin "$CI_COMMIT_BRANCH"
echo "Snapshot pushed to $CI_COMMIT_BRANCH"
fi

75
AGENTS.md Normal file
View File

@@ -0,0 +1,75 @@
# Agent guide: mifi Ventures landing
This file helps LLM agents work in this repo without introducing anti-patterns. Follow the architecture and conventions below.
## Purpose
- **Audience**: LLM agents (e.g. Cursor, Codex) making code or content changes.
- **Goal**: Preserve a minimal static site: SvelteKit prerender, no client-side app JS, shared theming, critical CSS inlining, and clear separation between app routes and static error pages.
## Stack and architecture
- **Framework**: SvelteKit with **adapter-static**. All routes are prerendered; there is no client-side router or hydration (`csr: false` in `src/routes/+layout.ts`).
- **Build**: `pnpm run build` = `vite build``node scripts/critters.mjs``node scripts/minify-static-js.mjs``node scripts/copy-410-paths.mjs`. Output is `dist/` (static files only).
- **Runtime**: nginx serves `dist/` (mounted as `/usr/share/nginx/html` in the container). No Node at runtime.
- **Theming**: CSS only. Light/dark follows **system preference** via `@media (prefers-color-scheme: dark)` in `src/app.css`. There is no JS theme toggle or `data-theme`; do not add one unless explicitly requested.
- **Fonts**: **Local only.** Inter and Fraunces are served from `static/assets/fonts/` (e.g. `inter-v20-latin-*.woff2`, `fraunces-v38-latin-*.woff2`). Preloads are in `src/routes/+layout.svelte`. Do not add Google Fonts or other external font URLs for the main site or error pages.
## Key paths
| Path | Role |
|------|------|
| `src/app.css` | Single global stylesheet: CSS variables (light/dark), base styles, components. Source of truth for theme tokens. |
| `src/app.html` | SvelteKit HTML shell. Rarely edited. |
| `src/routes/+layout.svelte` | Root layout: head (meta, fonts, favicon, scripts), skip link, slot. Imports `app.css`. |
| `src/routes/+layout.ts` | Exports `prerender = true`, `ssr = true`, `csr = false`. Do not enable CSR. |
| `src/routes/+page.svelte` | Home page; composes sections from `src/lib/components/`. |
| `src/lib/data/*.ts` | Content and meta (home-meta, content, experience, engagements, json-ld). Edit here for copy or SEO. |
| `src/lib/seo.ts` | SEO defaults (baseUrl, theme colors, etc.) and `mergeMeta()`. |
| `static/` | Copied as-is into `dist/` by SvelteKit. Favicon, robots.txt, fonts, images, **404.html**, **410.html**, and **assets/error-pages.css** live here. |
| `scripts/critters.mjs` | Post-build: inlines critical CSS into **every** `dist/*.html` (including 404 and 410). Resolves stylesheet URLs relative to `dist/`. |
| `scripts/minify-static-js.mjs` | Post-build: minifies JS in `dist/assets/`. |
| `scripts/copy-410-paths.mjs` | Post-build: copies `410.html` to each 410 URL path as `index.html` so static preview (e.g. `serve dist`) shows the 410 page at those URLs; nginx still returns 410 via explicit location blocks. |
| `nginx.conf` | Serves static files; `try_files $uri $uri/ /index.html` for SPA-style fallback; `error_page 404 /404.html` and `error_page 410 /410.html` for custom error pages. |
## Static error pages (404, 410)
- **Files**: `static/404.html`, `static/410.html`. They are **standalone HTML** (not Svelte). Do not convert them to Svelte routes.
- **Styling**: Both link to **one** shared stylesheet: `<link rel="stylesheet" href="/assets/error-pages.css">`. All error-page CSS lives in **`static/assets/error-pages.css`** (theme variables for light/dark, local `@font-face` for Inter/Fraunces, layout for `.error-page`). Do not duplicate theme tokens or add inline `<style>` in the HTML; keep a single source in `error-pages.css`.
- **Critical CSS**: The build runs Critters on all `dist/*.html`. Critters inlines critical CSS from linked stylesheets (including `/assets/error-pages.css`) into 404.html and 410.html. So:
- **Do** keep the `<link rel="stylesheet" href="/assets/error-pages.css">` in 404.html and 410.html; Critters will inline it at build time.
- **Do not** add error-page-only CSS in `src/app.css`; the app bundle is not loaded on error pages.
- **Theme alignment**: When changing light/dark colors or typography in `src/app.css`, update **`static/assets/error-pages.css`** so 404/410 stay visually consistent (same `--ep-*` tokens and, if needed, `@media (prefers-color-scheme: dark)`).
- **Local fonts**: Error pages use the same font paths as the main site (`/assets/fonts/...`) via `@font-face` in `error-pages.css`. Do not use Google Fonts or other external font URLs on error pages.
- **Preview vs production**: In preview (`serve dist`), the 410 URLs (e.g. `/pt/`, `/feed/`) are served by copying `410.html` to each path as `index.html` (see `scripts/copy-410-paths.mjs`). In production, nginx returns HTTP 410 for those paths and serves the same content via `error_page 410 /410.html`. If you add or remove 410 paths, update both `nginx.conf` and the `PATHS` array in `scripts/copy-410-paths.mjs`.
## Anti-patterns to avoid
1. **Enabling client-side rendering**
Do not set `csr: true` or add a client-side router. The site is intentionally static and JS-minimal.
2. **Adding app JavaScript for the main shell**
The only scripts are small, purposeful ones (e.g. `copyright-year.js`, `ga-init.js`, `mobile-menu-helper.js`) in `static/assets/js/`. Do not introduce a Svelte hydration bundle or large runtime for the main pages.
3. **External fonts**
Do not add `<link>` to Google Fonts (or similar) in layout or error pages. Use local fonts in `static/assets/fonts/` and reference them via preload (layout) or `@font-face` (error-pages.css).
4. **Skipping Critters for new HTML**
Any new `.html` in `static/` is copied to `dist/` and **must** be processed by Critters (the script already runs on all `dist/*.html`). Do not add static HTML that bypasses the build or that uses only inline styles without a linked stylesheet (linked styles get inlined by Critters).
5. **Diverging error page theme**
Do not change 404/410 styling in a way that ignores `static/assets/error-pages.css` or that duplicates theme tokens from `src/app.css` in ad-hoc form. Keep one error-page stylesheet and align its variables with `app.css` when you change the main theme.
6. **Breaking static export**
Do not add routes or behavior that require server-side rendering at request time (e.g. dynamic routes without prerender). The app is fully prerendered and served as static files.
7. **Scattering SEO or theme defaults**
Keep SEO defaults and theme-color values in `src/lib/seo.ts` and in layout/error pages that need them. Do not duplicate or hardcode them in many places.
## Quick reference
- **Change copy or structure (home)**: `src/lib/data/*.ts`, `src/lib/components/*.svelte`, `src/routes/+page.svelte`.
- **Change global styles or theme**: `src/app.css`. Then sync **`static/assets/error-pages.css`** if tokens or dark mode change.
- **Change error page copy or structure**: `static/404.html`, `static/410.html`. Style changes: **`static/assets/error-pages.css`** only.
- **Add a new static HTML page**: Add it under `static/`, link to `/assets/error-pages.css` (or a dedicated stylesheet that Critters can inline). Ensure `scripts/critters.mjs` runs over all `dist/*.html` (it already does).
- **Change nginx behavior**: `nginx.conf` (e.g. cache headers, `error_page`, `try_files`).

View File

@@ -1,7 +1,7 @@
# Static site container for mifi Ventures
# Build stage: run critical CSS inlining; final stage: serve dist/ via nginx
# Build stage: SvelteKit build + Critters; final stage: serve dist/ via nginx
# Stage 1: Build (critical CSS inlining + copy assets → dist/)
# Stage 1: Build (SvelteKit + critical CSS inlining → dist/)
FROM node:20-alpine AS builder
WORKDIR /app
@@ -12,8 +12,10 @@ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY build.mjs ./
COPY site/ ./site/
COPY svelte.config.js vite.config.ts tsconfig.json postcss.config.js ./
COPY src/ ./src/
COPY static/ ./static/
COPY scripts/ ./scripts/
RUN pnpm run build

247
README.md
View File

@@ -4,7 +4,8 @@ A minimal, production-ready static website for mifi Ventures, LLC — a software
## 🏗️ Technology Stack
- **Frontend**: Pure semantic HTML5, modern CSS with CSS variables, minimal JavaScript
- **Frontend**: SvelteKit (Svelte 5) with adapter-static — prerendered HTML/CSS, zero app JS (no hydration)
- **Build**: Vite, PostCSS (autoprefixer), Critters (critical CSS inlining)
- **Server**: nginx (Alpine Linux)
- **Containerization**: Docker
- **CI/CD**: Woodpecker CI
@@ -18,18 +19,24 @@ A minimal, production-ready static website for mifi Ventures, LLC — a software
-**WCAG 2.2 AAA oriented** with strong focus states, keyboard navigation, semantic markup
-**SEO optimized** with Open Graph, Twitter Cards, JSON-LD structured data
-**Performance optimized** with nginx gzip compression and cache headers
-**Zero frameworks** — pure HTML/CSS/JS for maximum speed and simplicity
-**Minimal JS** — only a tiny copyright-year script; no Svelte runtime or app bundle
## 🚀 Local Development
This project uses **pnpm** as the package manager. After cloning, run `pnpm install` (or ensure Corepack is enabled so `pnpm` is available).
| Command | Description |
|---------|-------------|
| `pnpm install` | Install dependencies |
| `pnpm run dev` | Serve `site/` at http://localhost:3000 with **live reload** (watcher) |
| `pnpm run build` | Copy `site/``dist/` and inline critical CSS in `index.html` |
| `pnpm run preview` | Serve built `dist/` to test production output |
| Command | Description |
| ------------------- | ------------------------------------------------------------------------------ |
| `pnpm install` | Install dependencies |
| `pnpm run dev` | SvelteKit dev server at http://localhost:5173 with **live reload** |
| `pnpm run build` | SvelteKit build → `dist/`, then Critters inlines critical CSS |
| `pnpm run preview` | Serve `dist/` (Critters-processed) at http://localhost:4173 — same as deployed |
| `pnpm test` | Run unit tests (Vitest) |
| `pnpm run test:e2e` | Run Playwright visual regression (e2e) |
| `pnpm run test:e2e:update-snapshots` | Regenerate e2e snapshots (in devcontainer = CI; see Visual regression) |
| `pnpm run lint` | ESLint (JS/TS/Svelte) |
| `pnpm run lint:css` | Stylelint (global CSS + Svelte styles) |
| `pnpm run format` | Prettier (JS/TS/Svelte/CSS/JSON) |
### Option 1: pnpm dev (recommended for editing)
@@ -39,35 +46,26 @@ From the project root:
pnpm run dev
```
Opens http://localhost:3000 with live reload when you change files in `site/`.
Opens http://localhost:5173 with live reload when you change files in `src/` or `static/`.
### Option 2: Other local servers (quick start)
### Option 2: Preview production build
Open `site/index.html` directly in a browser, or use a simple HTTP server:
After building, serve the static output:
```bash
# Python 3
cd site
python3 -m http.server 8000
# Node (if you prefer not to use pnpm dev)
cd site
pnpm exec serve .
# PHP
cd site
php -S localhost:8000
pnpm run build
pnpm run preview
```
Then visit the URL shown (e.g. `http://localhost:8000`).
Preview uses `serve dist` so you see the same HTML/CSS as in production (including critical CSS).
### Option 3: Dev Container
Open the project in a dev container for a consistent local environment:
Open the project in a dev container for a consistent local environment. The devcontainer uses the **same image as CI** (`mcr.microsoft.com/playwright:v1.58.0-noble`), so e2e snapshots generated inside it match CI.
1. **Open in Cursor or VS Code** with the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed.
2. **Reopen in Container**: Command Palette (`Cmd/Ctrl+Shift+P`) → **Dev Containers: Reopen in Container**.
3. Wait for the container to build and start.
3. Wait for the container to build and start. (If you already had a devcontainer open, use **Rebuild Container** once to pick up the Playwright Noble image.)
**Inside the container**, run:
@@ -76,7 +74,21 @@ pnpm install
pnpm run dev
```
The site is served at **http://localhost:3000** with live reload (port forwarded automatically).
The site is served at **http://localhost:5173** (or the port shown) with live reload (port forwarded automatically). Run `pnpm run test:e2e:update-snapshots` here to regenerate the Linux snapshot when the layout changes.
### Visual regression (e2e)
E2e tests use Playwright and compare full-page screenshots to committed snapshots. CI and the **devcontainer** both use `mcr.microsoft.com/playwright:v1.58.0-noble`, so snapshots generated in the devcontainer match CI.
**To update the Linux snapshot locally (devcontainer):**
1. Rebuild the devcontainer once (so it uses the Playwright Noble image; see Option 3 below).
2. Run `pnpm run test:e2e:update-snapshots` (no Docker needed — same environment as CI).
3. Commit the updated file(s) under `tests/visual.spec.ts-snapshots/`.
**If youre not using the devcontainer:** run the **update-e2e-snapshots** workflow manually in Woodpecker (requires a `git_push_token` secret), or run `pnpm run test:e2e:update-snapshots` on a host with Docker.
Local `pnpm run test:e2e` on **macOS** uses the Darwin snapshot; the Linux snapshot is used in CI and in the devcontainer.
### Option 4: Docker (Production-like Test)
@@ -91,53 +103,51 @@ Then visit: `http://localhost:8080`. Stop with `docker stop mifi-ventures-landin
## 📝 Content Updates
The HTML file includes an editable constants block at the top for easy updates:
Content and links are driven by data and components:
```html
<!--
EDITABLE CONSTANTS:
- DOMAIN
- ORG_NAME
- PRINCIPAL_NAME
- CAL_LINK
- RESUME_PATH
- LINKEDIN_URL
- GITHUB_URL
-->
```
- **Meta/SEO**: `src/lib/seo.ts`, `src/lib/data/home-meta.ts`, and per-route `+page.ts` load functions
- **Section copy**: `src/lib/data/content.ts`, `src/lib/data/experience.ts`, `src/lib/data/engagements.ts`
- **JSON-LD**: `src/lib/data/json-ld.ts`
- **Layout and sections**: `src/routes/+layout.svelte`, `src/routes/+page.svelte`, and components in `src/lib/components/`
Update these values directly in `site/index.html` to modify:
- Company information
- Calendar booking link
- Social media links
- Resume file path
To change company info, calendar link, social links, or resume path, edit the data modules and `src/lib/data/home-meta.ts` (or the relevant routes meta).
## 🗂️ Project Structure
```
mifi-ventures-landing/
├── .devcontainer/ # Dev container for local development
│ ├── devcontainer.json # Dev container config (port 3000, extensions)
│ └── Dockerfile # Dev container image (Node + serve)
├── .woodpecker.yml # CI/CD pipeline configuration
│ ├── devcontainer.json # Dev container config (extensions)
│ └── Dockerfile # Dev container image (Node)
├── .woodpecker/ # CI/CD pipelines (see below)
│ ├── ci.yaml # one clone/workspace: install → lint → build → test
│ └── deploy.yaml # Docker → push → webhook (main only, after ci)
├── Dockerfile # Production container (nginx:alpine)
├── nginx.conf # nginx web server configuration
├── README.md # This file
├── .gitignore # Git ignore rules
── site/ # Static website files
├── index.html # Main HTML file
├── styles.css # CSS styles (light/dark mode)
├── script.js # Minimal JavaScript (dynamic year)
├── robots.txt # Search engine directives
── favicon.svg # Site favicon
└── assets/
├── resume.pdf # Resume download (placeholder)
└── logos/ # Company logo SVGs
├── atlassian.svg
├── tjx.svg
├── cargurus.svg
├── timberland.svg
└── mfa-boston.svg
├── svelte.config.js # SvelteKit config (adapter-static)
├── vite.config.ts # Vite config
── postcss.config.js # PostCSS (autoprefixer)
├── scripts/critters.mjs # Post-build critical CSS inlining
├── static/ # Static assets (copied to dist as-is)
├── favicon.svg, favicon.ico, robots.txt
├── copyright-year.js # Minimal client script (footer year)
── assets/ # Fonts, images, logos, resume.pdf, og-image.png
├── src/
├── app.css # Global tokens + base styles
│ ├── app.html # HTML shell for SvelteKit
│ ├── app.d.ts # SvelteKit types
│ ├── routes/
├── +layout.ts # Prerender, csr: false
├── +layout.svelte # Shell, head, skip link, slot
├── +page.ts # Home page meta (load)
│ │ └── +page.svelte # Home page content (components)
│ └── lib/
│ ├── seo.ts # Meta defaults, mergeMeta, PageMeta type
│ ├── copyright-year.ts
│ ├── data/ # home-meta, json-ld, content, experience, engagements
│ └── components/ # Hero, sections, Footer, Logo, etc.
├── tests/ # Playwright visual regression
└── README.md # This file
```
## 🚢 CI/CD Deployment (Woodpecker + Gitea)
@@ -146,9 +156,12 @@ mifi-ventures-landing/
### Pipeline Overview
The `.woodpecker.yml` pipeline automates deployment on push to `main`:
Woodpecker uses two workflows (`.woodpecker/ci.yaml`, `deploy.yaml`):
- **Pull requests** (and **push/tag/manual on main**): **ci** runs install → lint → build → test in one workspace (one clone, one install). No Docker or deploy on PRs.
- **Push to main** (or tag / manual on main): After ci succeeds, **deploy** runs:
1. **Build** — Builds Docker image tagged with commit SHA + `latest`
1. **Build** — Builds Docker image tagged with commit SHA + `latest`
2. **Push** — Pushes images to private Docker registry
3. **Deploy** — SSH to Linode VPS, pulls latest image, restarts container with health checks
@@ -158,15 +171,17 @@ The `.woodpecker.yml` pipeline automates deployment on push to `main`:
Navigate to your repository → Settings → Secrets and add:
| Secret Name | Description | Example |
|-------------|-------------|---------|
| `registry_password` | Docker registry password or token | `dckr_pat_xxxxx` |
| `deploy_host` | Linode VPS hostname or IP | `vps.example.com` or `192.0.2.1` |
| `deploy_username` | SSH username | `deploy` or `root` |
| `deploy_ssh_key` | Private SSH key (multi-line) | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
| `deploy_port` | SSH port | `22` (default) |
| Secret Name | Description | Example |
| ------------------- | --------------------------------- | ---------------------------------------- |
| `registry_password` | Docker registry password or token | `dckr_pat_xxxxx` |
| `deploy_host` | Linode VPS hostname or IP | `vps.example.com` or `192.0.2.1` |
| `deploy_username` | SSH username | `deploy` or `root` |
| `deploy_ssh_key` | Private SSH key (multi-line) | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
| `deploy_port` | SSH port | `22` (default) |
| `git_push_token` | *(Optional)* Repo push token for manual **update-e2e-snapshots** workflow (fallback when not using devcontainer) | Gitea/Forgejo personal access token |
**Generate SSH key for deployment:**
```bash
ssh-keygen -t ed25519 -C "woodpecker-deploy" -f ~/.ssh/woodpecker_deploy
# Add public key to server: ssh-copy-id -i ~/.ssh/woodpecker_deploy.pub user@host
@@ -177,13 +192,13 @@ ssh-keygen -t ed25519 -C "woodpecker-deploy" -f ~/.ssh/woodpecker_deploy
Set these as repository or organization-level variables:
| Variable | Description | Example |
|----------|-------------|---------|
| `REGISTRY_URL` | Docker registry base URL | `registry.example.com` |
| `REGISTRY_REPO` | Full image repository path | `registry.example.com/mifi-ventures-landing` |
| `REGISTRY_USERNAME` | Registry username | `myusername` |
| `CONTAINER_NAME` | Container name on server | `mifi-ventures-landing` |
| `APP_PORT` | Host port to expose | `8080` (or `80` if direct) |
| Variable | Description | Example |
| ------------------- | -------------------------- | -------------------------------------------- |
| `REGISTRY_URL` | Docker registry base URL | `registry.example.com` |
| `REGISTRY_REPO` | Full image repository path | `registry.example.com/mifi-ventures-landing` |
| `REGISTRY_USERNAME` | Registry username | `myusername` |
| `CONTAINER_NAME` | Container name on server | `mifi-ventures-landing` |
| `APP_PORT` | Host port to expose | `8080` (or `80` if direct) |
#### Example Configuration
@@ -191,22 +206,21 @@ Set these as repository or organization-level variables:
```yaml
# Secrets (Values tab)
registry_password: "your-registry-token"
deploy_host: "123.45.67.89"
deploy_username: "deploy"
registry_password: 'your-registry-token'
deploy_host: '123.45.67.89'
deploy_username: 'deploy'
deploy_ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
-----END OPENSSH PRIVATE KEY-----
deploy_port: "22"
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
-----END OPENSSH PRIVATE KEY-----
deploy_port: '22'
# Environment Variables (Variables tab)
REGISTRY_URL: "registry.example.com"
REGISTRY_REPO: "registry.example.com/mifi-ventures-landing"
REGISTRY_USERNAME: "myuser"
CONTAINER_NAME: "mifi-ventures-landing"
APP_PORT: "8080"
REGISTRY_URL: 'registry.example.com'
REGISTRY_REPO: 'registry.example.com/mifi-ventures-landing'
REGISTRY_USERNAME: 'myuser'
CONTAINER_NAME: 'mifi-ventures-landing'
APP_PORT: '8080'
```
### Pipeline Features
@@ -221,15 +235,21 @@ APP_PORT: "8080"
### Troubleshooting
**Build fails:**
```bash
# Build locally first (must succeed before Docker)
pnpm install
pnpm run build
# Check Dockerfile syntax
docker build -t test .
# Verify files are present
ls -la site/
# Verify source is present
ls -la src/ static/
```
**Push fails:**
```bash
# Test registry login locally
echo "PASSWORD" | docker login registry.example.com -u username --password-stdin
@@ -238,6 +258,7 @@ echo "PASSWORD" | docker login registry.example.com -u username --password-stdin
```
**Deploy fails:**
```bash
# Test SSH connection
ssh -i ~/.ssh/key user@host "docker ps"
@@ -250,6 +271,7 @@ ssh user@host "docker --version"
```
**Container fails health check:**
```bash
# SSH to server and check logs
ssh user@host "docker logs mifi-ventures-landing"
@@ -285,6 +307,7 @@ EOF
The custom `nginx.conf` provides optimized static file delivery:
### Caching Strategy
- **HTML files**: `no-cache, must-revalidate` (always fresh from server)
- **CSS/JS**: `max-age=31536000, immutable` (1 year, content-addressed)
- **Images** (JPG, PNG, WebP, AVIF): `max-age=2592000` (30 days)
@@ -295,7 +318,9 @@ The custom `nginx.conf` provides optimized static file delivery:
- **favicon.svg**: `max-age=2592000` (30 days)
### Gzip Compression
Enabled for all text-based content with compression level 6:
- HTML, CSS, JavaScript
- JSON, XML
- SVG images
@@ -303,6 +328,7 @@ Enabled for all text-based content with compression level 6:
Minimum size: 256 bytes (avoids compressing tiny files)
### Other Features
- **Server tokens**: Disabled for security
- **Access logs**: Disabled for static assets (performance)
- **Hidden files**: Denied (.git, .env, etc.)
@@ -310,6 +336,7 @@ Minimum size: 256 bytes (avoids compressing tiny files)
- **Health check**: Available on port 80 for container orchestration
### Security Headers
**Note**: Security headers (CSP, HSTS, X-Frame-Options, etc.) are handled upstream by Traefik and are NOT included in this nginx configuration to avoid duplication.
## 🎯 SEO & Performance
@@ -317,6 +344,7 @@ Minimum size: 256 bytes (avoids compressing tiny files)
### Current Optimizations
#### On-Page SEO
- **Title tag**: Includes business name, service, and location
- **Meta description**: Natural, compelling copy (155 characters) emphasizing Boston location and services
- **Canonical URL**: Set to `https://mifi.ventures/` to prevent duplicate content issues
@@ -327,17 +355,20 @@ Minimum size: 256 bytes (avoids compressing tiny files)
- **Language declaration**: `lang="en-US"` for US English
#### Social Media Share Previews
- **Open Graph tags**: Complete OG implementation for Facebook, LinkedIn
- Site name, title, description, URL, image
- Image dimensions (1200x630px) and alt text
- Locale set to `en_US`
- Site name, title, description, URL, image
- Image dimensions (1200x630px) and alt text
- Locale set to `en_US`
- **Twitter Cards**: `summary_large_image` card with full metadata
- Creator and site handles (update with actual Twitter)
- Image with alt text for accessibility
- Creator and site handles (update with actual Twitter)
- Image with alt text for accessibility
- **Theme colors**: Dynamic based on light/dark mode preference
#### Structured Data (JSON-LD)
Comprehensive @graph structure with interconnected entities:
- **Organization** (`#organization`): mifi Ventures, LLC with Boston address, geo coordinates, and service catalog
- **Person** (`#principal`): Mike Fitzpatrick as "Principal Software Engineer and Architect" with worksFor relationship and knowsAbout expertise areas
- **WebSite** (`#website`): Site-level metadata with ReserveAction pointing to Cal.com scheduling
@@ -347,6 +378,7 @@ Comprehensive @graph structure with interconnected entities:
- **No email or phone**: Complies with privacy requirements
#### Technical SEO
- **robots.txt**: Properly configured for full site crawling
- **Lazy loading**: Images load on-demand for performance
- **Minimal JavaScript**: Only essential scripts (copyright year)
@@ -358,6 +390,7 @@ Comprehensive @graph structure with interconnected entities:
### Action Items
Before launch, update these placeholders:
1. Create OG image: 1200x630px PNG at `/assets/og-image.png`
2. Update Twitter handles in meta tags (lines 57-58) if you have a Twitter presence
3. Update GitHub URL in footer and constants if you want to include it (currently optional)
@@ -365,6 +398,7 @@ Before launch, update these placeholders:
### SEO Testing & Validation
Before going live, validate with these tools:
- **Google Search Console**: Submit site, monitor indexing
- **Rich Results Test**: Verify JSON-LD structured data
- **Facebook Sharing Debugger**: Test OG tags preview
@@ -374,6 +408,7 @@ Before going live, validate with these tools:
- **PageSpeed Insights**: Check Core Web Vitals
Key metrics to monitor post-launch:
- Indexing status in Google Search Console
- Click-through rates (CTR) from search results
- Share engagement on social platforms
@@ -398,6 +433,7 @@ This site is built to meet **WCAG 2.2 Level AAA** standards wherever applicable
### Implemented Features
#### Keyboard Navigation
- **Skip link**: Visible on keyboard focus, jumps directly to main content (`#main`)
- **Logical tab order**: All interactive elements follow natural reading order
- **No keyboard traps**: Users can navigate through and exit all interactive regions
@@ -405,36 +441,42 @@ This site is built to meet **WCAG 2.2 Level AAA** standards wherever applicable
- **Focus never removed**: Outline styles are enforced with `!important` to prevent accidental removal
#### Semantic Structure
- **Proper landmarks**: `<header>`, `<main>`, `<footer>`, and `<nav>` for clear page regions
- **Single H1**: One `<h1>` element ("mifi Ventures") with logical H2 nesting for all sections
- **ARIA labelledby**: All sections connected to their headings via `aria-labelledby` attributes
- **Language declaration**: `lang="en-US"` attribute on `<html>` element
#### Visual & Color
- **AAA contrast ratios**: All text meets AAA standards (7:1 for normal text, 4.5:1 for large text)
- Light mode: `#1a1a1a` text on `#ffffff` background (16.1:1 ratio)
- Dark mode: `#f5f5f5` text on `#0a0a0a` background (18.4:1 ratio)
- Light mode: `#1a1a1a` text on `#ffffff` background (16.1:1 ratio)
- Dark mode: `#f5f5f5` text on `#0a0a0a` background (18.4:1 ratio)
- **Color independence**: No information conveyed by color alone
- **High contrast mode**: Enhanced borders, outlines, and contrast for users with `prefers-contrast: high`
#### Interactive Elements
- **Adequate touch targets**: All buttons and links meet minimum 44x44px size (AAA requirement)
- **Descriptive link text**: All links have meaningful text or enhanced ARIA labels
- **External link warnings**: Links opening in new tabs clearly labeled "(opens in new tab)"
- **Button spacing**: Generous gaps between CTAs prevent accidental activation
#### Motion & Animation
- **Respects `prefers-reduced-motion`**: All animations and transforms disabled when users prefer reduced motion
- **Safe default animations**: Subtle hover effects that don't cause vestibular issues
- **No auto-playing content**: No carousels, videos, or content that moves automatically
#### Images & Media
- **Descriptive alt text**: All images have clear, concise alternative text
- **Text fallbacks**: Logo strip includes visually-hidden text that appears if images fail
- **Mobile text list**: On small screens, logo images replaced with accessible text list
- **Decorative images marked**: Images that don't convey content use appropriate ARIA attributes
#### Screen Reader Support
- **Clear labels**: All form controls, buttons, and navigation have proper labels
- **ARIA landmarks**: Supplementary ARIA roles for enhanced screen reader navigation
- **Visually-hidden content**: Important text available to screen readers but hidden visually where appropriate
@@ -443,6 +485,7 @@ This site is built to meet **WCAG 2.2 Level AAA** standards wherever applicable
### Testing Recommendations
For best results, test with:
- **Keyboard only**: Tab through entire page without mouse
- **Screen readers**: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS), TalkBack (Android)
- **Browser extensions**: axe DevTools, WAVE, Lighthouse accessibility audit

View File

@@ -1,164 +0,0 @@
# SEO Pre-Launch Checklist
Use this checklist before deploying to production.
## ✅ Meta Tags
- [ ] Title tag is descriptive and includes location (under 60 characters)
- [ ] Meta description is compelling and natural (150-160 characters)
- [ ] Canonical URL is set to `https://mifi.ventures/`
- [ ] Robots meta allows indexing (`index, follow`)
- [ ] Language is declared (`lang="en-US"`)
- [ ] Author meta tag is present
- [ ] Geographic meta tags include Boston coordinates
## ✅ Open Graph Tags
- [ ] `og:type` is set to "website"
- [ ] `og:url` matches canonical URL
- [ ] `og:site_name` is "mifi Ventures"
- [ ] `og:title` is descriptive and compelling
- [ ] `og:description` matches meta description
- [ ] `og:image` points to actual 1200x630px image
- [ ] `og:image:width` and `og:image:height` are specified
- [ ] `og:image:alt` provides context
- [ ] `og:locale` is set to "en_US"
## ✅ Twitter Cards
- [ ] Card type is `summary_large_image`
- [ ] `twitter:title` matches OG title
- [ ] `twitter:description` matches OG description
- [ ] `twitter:image` matches OG image
- [ ] `twitter:image:alt` is descriptive
- [ ] `twitter:creator` handle is updated (if applicable)
- [ ] `twitter:site` handle is updated (if applicable)
## ✅ Structured Data (JSON-LD)
- [ ] Uses @graph structure with stable @id anchors
- [ ] **Organization** entity (`#organization`) is complete
- [ ] Legal name matches LLC
- [ ] Boston address is accurate
- [ ] Geographic coordinates are correct
- [ ] hasOfferCatalog links to services
- [ ] **Person** entity (`#principal`) is complete
- [ ] Name and title are accurate
- [ ] worksFor links to organization
- [ ] knowsAbout lists relevant expertise
- [ ] LinkedIn URL is correct (https://linkedin.com/in/the-mifi)
- [ ] **WebSite** entity (`#website`) is complete
- [ ] potentialAction/ReserveAction points to Cal.com
- [ ] Action name is descriptive
- [ ] **WebPage** entity (`#webpage`) is complete
- [ ] isPartOf links to website
- [ ] primaryImageOfPage is set
- [ ] inLanguage is "en-US"
- [ ] **OfferCatalog** entity (`#services`) is complete
- [ ] All 6 services from "What We Do" are listed
- [ ] Descriptions match page copy
- [ ] No email or phone anywhere in JSON-LD
- [ ] All @id values use mifi.ventures domain
## ✅ Content & Copy
- [ ] No keyword stuffing in any content
- [ ] Copy sounds natural and professional
- [ ] Boston location is mentioned naturally
- [ ] Services are clearly described
- [ ] No email or phone numbers in meta tags
- [ ] All text is grammatically correct
- [ ] Tone matches brand (professional, technical, credible)
## ✅ Technical SEO
- [ ] `robots.txt` exists and allows crawling
- [ ] All images have descriptive alt text
- [ ] Heading hierarchy is correct (one H1, logical H2s)
- [ ] Links have descriptive anchor text
- [ ] No broken links (404s)
- [ ] HTTPS is enforced (if applicable)
- [ ] Mobile-responsive design
- [ ] Fast page load (< 3 seconds)
- [ ] favicon.svg is present and loads
## ✅ Assets
- [ ] OG image created (1200x630px, under 1MB)
- [ ] OG image uploaded to `/assets/og-image.png`
- [ ] OG image looks good when scaled down
- [ ] OG image includes readable text
- [ ] Resume PDF is uploaded (if publishing)
- [ ] Company logos are actual logos (not placeholders)
## ✅ External Links
- [ ] LinkedIn URL updated in constants and JSON-LD
- [ ] GitHub URL updated in constants and JSON-LD
- [ ] Cal.com link is correct and working
- [ ] All external links open in new tab with `rel="noopener noreferrer"`
## ✅ Pre-Launch Testing
Run these tests and fix any issues:
### Google Tools
- [ ] Google Rich Results Test: [https://search.google.com/test/rich-results](https://search.google.com/test/rich-results)
- Should show valid ProfessionalService schema
- [ ] Google Mobile-Friendly Test: [https://search.google.com/test/mobile-friendly](https://search.google.com/test/mobile-friendly)
- Should pass without issues
- [ ] Google Lighthouse (in Chrome DevTools)
- SEO score: 100/100
- Performance: 90+/100
- Accessibility: 100/100
- Best Practices: 100/100
### Social Media Preview Tools
- [ ] Facebook Sharing Debugger: [https://developers.facebook.com/tools/debug/](https://developers.facebook.com/tools/debug/)
- Preview looks correct
- Image loads properly
- [ ] Twitter Card Validator: [https://cards-dev.twitter.com/validator](https://cards-dev.twitter.com/validator)
- Card preview looks good
- Image displays correctly
- [ ] LinkedIn Post Inspector: [https://www.linkedin.com/post-inspector/](https://www.linkedin.com/post-inspector/)
- Preview is accurate
### SEO Validators
- [ ] Schema.org Validator: [https://validator.schema.org/](https://validator.schema.org/)
- No errors in JSON-LD
- [ ] W3C HTML Validator: [https://validator.w3.org/](https://validator.w3.org/)
- No critical HTML errors
### Manual Checks
- [ ] View source and verify all meta tags are present
- [ ] Test share preview by sharing URL on social media
- [ ] Check that `robots.txt` is accessible at `https://mifi.ventures/robots.txt`
- [ ] Verify canonical URL in browser DevTools
- [ ] Test page on mobile device
- [ ] Verify skip link appears on Tab key press
## ✅ Post-Launch
After going live:
- [ ] Submit site to Google Search Console
- [ ] Submit site to Bing Webmaster Tools
- [ ] Create and submit sitemap.xml (future enhancement)
- [ ] Share on social media to test previews
- [ ] Monitor Google Search Console for indexing issues
- [ ] Set up Google Analytics (if desired)
- [ ] Monitor Core Web Vitals
- [ ] Check search appearance after 1-2 weeks
## 📝 Notes
Record any issues or observations during testing:
```
[Add your notes here]
```
---
**Last Updated**: [Date]
**Reviewed By**: [Name]

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
/**
* Build script: copies entire site/ to dist/, then inlines critical CSS in dist/index.html.
* Uses Critters (no headless browser) so the build runs in any environment.
* Dockerfile copies only dist/ — single source of truth for the built site.
*/
import Critters from "critters";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname);
const SITE = path.join(ROOT, "site");
const DIST = path.join(ROOT, "dist");
async function main() {
// Copy entire site structure to dist
if (fs.existsSync(DIST)) {
fs.rmSync(DIST, { recursive: true });
}
fs.cpSync(SITE, DIST, { recursive: true });
console.log("✓ Copied site/ → dist/");
// Inline critical CSS in dist/index.html (Critters reads/writes relative to path)
const indexPath = path.join(DIST, "index.html");
let html = fs.readFileSync(indexPath, "utf8");
const critters = new Critters({
path: DIST,
preload: "swap",
noscriptFallback: true,
pruneSource: false,
logLevel: "warn",
});
html = await critters.process(html);
fs.writeFileSync(indexPath, html, "utf8");
console.log("✓ Critical CSS inlined → dist/index.html");
console.log("Build complete. Output: dist/");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -10,7 +10,8 @@ services:
pull_policy: always
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
test:
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
start_period: 5s
@@ -20,7 +21,7 @@ services:
- "traefik.docker.network=marina-net"
- "traefik.http.routers.mifiventures.rule=Host(`mifi.ventures`) || Host(`www.mifi.ventures`)"
- "traefik.http.routers.mifiventures.entrypoints=websecure"
- "traefik.http.routers.mifiventures.middlewares=secure-all@file,redirect-www-to-non-www@file"
- "traefik.http.routers.mifiventures.middlewares=security-supermax-with-analytics@file,redirect-www-to-non-www@file"
- "traefik.http.routers.mifiventures.tls=true"
- "traefik.http.routers.mifiventures.tls.certresolver=letsencrypt"
networks:

View File

@@ -1,6 +1,6 @@
# Deployment Guide
Woodpecker builds the site, pushes the image to **Giteas container registry**, then triggers a **Portainer stack redeploy** via webhook. The stack on your Linode VPS pulls the new image and recreates the container.
Woodpecker runs the SvelteKit build (`pnpm run build``dist/`), builds the Docker image from that output, pushes the image to **Giteas container registry**, then triggers a **Portainer stack redeploy** via webhook. The stack on your Linode VPS pulls the new image and recreates the container. Opening a pull request runs a separate workflow (lint, tests, and a test build) on the branch without building or pushing the Docker image.
## Portainer stack options
@@ -34,9 +34,11 @@ Portainer pulls `git.mifi.dev/mifi-ventures/landing:latest`. If that image has n
**Option B Push from your machine:**
Use Docker (or OrbStack, Colima, Rancher Desktop) from the repo root. **If youre on Apple Silicon (M1/M2/M3) or another ARM Mac**, the VPS is x86_64, so build for that platform to avoid “exec format error”:
From the repo root, run `pnpm install` and `pnpm run build` to produce `dist/`, then use Docker (or OrbStack, Colima, Rancher Desktop). **If youre on Apple Silicon (M1/M2/M3) or another ARM Mac**, the VPS is x86_64, so build for that platform to avoid “exec format error”:
```bash
pnpm install
pnpm run build # SvelteKit → dist/; Critters inlines critical CSS
docker login git.mifi.dev # use your Gitea username and token/password
docker buildx build --platform linux/amd64 -t git.mifi.dev/mifi-ventures/landing:latest --push .
```
@@ -86,17 +88,25 @@ Woodpecker has no separate “Variables” UI — add everything under **Repo
| `registry_password` | Gitea token or password (package write to the repos registry) |
| `portainer_webhook_url` | Portainer stack webhook URL (from step 2) |
`REGISTRY_URL` and `REGISTRY_REPO` are set in `.woodpecker.yml`; you dont need to add them anywhere.
`REGISTRY_URL` and `REGISTRY_REPO` are set in `.woodpecker/deploy.yaml`; you dont need to add them anywhere.
### 5. Test Deployment
#### Local Test Build
The Docker image is built from the contents of `dist/`. That directory is produced by the SvelteKit build, so you must run `pnpm run build` before `docker build` (or let the Dockerfile run it inside the image).
```bash
# Clone repo
git clone https://git.mifi.dev/mifi-ventures/landing.git
cd landing
# Build locally
# Install deps and build static site (SvelteKit + Critters)
pnpm install
pnpm run build
# Build Docker image (uses dist/ from the previous step if built on host,
# or the Dockerfile runs the build inside the container)
docker build -t test .
# Run locally
@@ -109,6 +119,8 @@ curl http://localhost:8080
docker stop test && docker rm test
```
To test the static site without Docker, run `pnpm run preview` after `pnpm run build` and open the URL shown (e.g. http://localhost:4173).
#### Trigger CI/CD
```bash
# Make a small change
@@ -247,5 +259,5 @@ If a bad image was deployed:
---
**Last Updated**: 2026-01-30
**Last Updated**: 2026-01-31
**Maintainer**: Mike Fitzpatrick

46
eslint.config.js Normal file
View File

@@ -0,0 +1,46 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import svelteConfig from './svelte.config.js';
export default [
{
ignores: [
'.svelte-kit/**',
'dist/**',
'build/**',
'node_modules/**',
'site/**',
'static/**',
'build.mjs',
'playwright-report/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
projectService: true,
extraFileExtensions: ['.svelte'],
svelteConfig,
},
},
},
{
files: ['**/*.mjs', 'build.mjs'],
languageOptions: { globals: { console: 'readonly', process: 'readonly' } },
},
{
rules: {
'svelte/no-at-html-tags': 'warn',
'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off',
},
},
];

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -108,14 +108,42 @@ http {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files (.git, .env, etc.)
# 410 Gone: permanently removed URLs (tells crawlers to deindex)
error_page 410 /410.html;
location = /410.html {
add_header Cache-Control "no-cache, must-revalidate";
add_header Content-Type "text/html; charset=utf-8";
}
location = /2024/02/18/hello-world/ { return 410; }
location = /2024/02/18/hello-world { return 410; }
location = /pt/ { return 410; }
location = /pt { return 410; }
location = /feed/ { return 410; }
location = /feed { return 410; }
location = /category/uncategorized/feed/ { return 410; }
location = /category/uncategorized/feed { return 410; }
location = /category/uncategorized/ { return 410; }
location = /category/uncategorized { return 410; }
location = /comments/feed/ { return 410; }
location = /comments/feed { return 410; }
# Allow .well-known (security.txt, ACME challenge, etc.)
location ^~ /.well-known/ {
add_header Cache-Control "public, max-age=86400";
}
# Deny access to other hidden files (.git, .env, etc.)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# 404 falls back to index.html for SPA-style routing
error_page 404 /index.html;
# Custom 404 page (for missing static assets; SPA routes still try index.html first via try_files)
error_page 404 /404.html;
location = /404.html {
add_header Cache-Control "no-cache, must-revalidate";
add_header Content-Type "text/html; charset=utf-8";
}
}
}

View File

@@ -1,17 +1,61 @@
{
"name": "mifi-ventures-landing",
"version": "1.0.0",
"private": true,
"repository": "https://git.mifi.dev/mifi-ventures/landing.git",
"packageManager": "pnpm@9.15.0",
"description": "mifi Ventures landing site — static build with critical CSS inlining",
"scripts": {
"build": "node build.mjs",
"preview": "npx serve dist",
"dev": "live-server site --port=3000 --open=/"
},
"devDependencies": {
"critters": "^0.0.24",
"live-server": "^1.2.2"
}
"name": "mifi-ventures-landing",
"version": "2.0.0",
"private": true,
"repository": "https://git.mifi.dev/mifi-ventures/landing.git",
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc",
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
"type": "module",
"scripts": {
"build": "vite build && node scripts/critters.mjs && node scripts/minify-static-js.mjs && node scripts/copy-410-paths.mjs",
"dev": "vite dev",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write \"src/**/*.{ts,js,svelte,css,json}\"",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:css": "stylelint \"src/**/*.css\" \"src/**/*.svelte\"",
"lint:css:fix": "stylelint \"src/**/*.css\" \"src/**/*.svelte\" --fix",
"preview": "serve dist -p 4173",
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:update-snapshots": "bash scripts/update-e2e-snapshots.sh",
"test:all": "vitest run && playwright test",
"test:watch": "vitest"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/gtag.js": "^0.0.20",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"critters": "^0.0.24",
"esbuild": "^0.24.0",
"eslint": "^9.39.2",
"jsdom": "^25.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"postcss-html": "^1.8.1",
"postcss-preset-env": "^11.1.2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"serve": "^14.2.5",
"stylelint": "^17.1.0",
"stylelint-config-standard": "^40.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-eslint-parser": "^1.4.1",
"tslib": "^2.8.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.54.0",
"vite": "^6.0.0",
"vitest": "^4.0.18"
},
"dependencies": {
"@lucide/svelte": "^0.563.1"
}
}

24
playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
testMatch: /.*\.spec\.(ts|js)/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: process.env.CI
? undefined
: {
command: 'pnpm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
});

5802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

17
postcss.config.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* PostCSS: nesting, future CSS (stage 2), and autoprefixing.
* postcss-preset-env compiles nesting (&), custom properties, and other
* stage-2 features, and runs autoprefixer from browserslist.
*/
export default {
plugins: {
'postcss-preset-env': {
stage: 2,
features: {
'nesting-rules': true,
'custom-properties': true,
},
autoprefixer: { grid: true },
},
},
};

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
/**
* Post-build: copy 410.html to each 410-Gone URL path as index.html.
* So static preview (serve dist) shows the 410 page at those URLs.
* nginx still returns 410 for these paths via explicit location blocks.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DIST = path.join(__dirname, '..', 'dist');
const SOURCE = path.join(DIST, '410.html');
const PATHS = [
'2024/02/18/hello-world',
'pt',
'feed',
'category/uncategorized/feed',
'category/uncategorized',
'comments/feed',
];
function main() {
if (!fs.existsSync(SOURCE)) {
console.error('dist/410.html not found. Run build first.');
process.exit(1);
}
const content = fs.readFileSync(SOURCE, 'utf8');
for (const dir of PATHS) {
const dirPath = path.join(DIST, dir);
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, 'index.html'), content, 'utf8');
}
console.log('✓ 410 page copied to', PATHS.length, 'paths for preview.');
}
main();

71
scripts/critters.mjs Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Post-build: inline critical CSS in dist/*.html (SvelteKit adapter-static output).
* Runs after vite build; Critters reads/writes relative to dist/.
*
* Critters with preload:'swap' adds onload but does not set rel="preload" as="style",
* so the link stays render-blocking. We fix that in postProcessSwapLinks().
*/
import Critters from 'critters';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
/**
* Critters leaves rel="stylesheet" on swap links; change to rel="preload" as="style"
* so the full CSS loads async and only applies on load (non-blocking).
*/
// function postProcessSwapLinks(html) {
// return html.replace(/<link\s+([^>]*)>/gi, (full, attrs) => {
// if (
// !/rel="stylesheet"/i.test(attrs) ||
// !/onload="this\.rel='stylesheet'"/i.test(attrs)
// ) {
// return full;
// }
// const fixed = attrs
// .replace(/\brel="stylesheet"\s*/i, 'rel="preload" as="style" ')
// .replace(
// /\bonload="this\.rel='stylesheet'"/i,
// 'onload="this.onload=null;this.rel=\'stylesheet\'"',
// );
// return `<link ${fixed}>`;
// });
// }
async function main() {
if (!fs.existsSync(DIST)) {
console.error('dist/ not found. Run vite build first.');
process.exit(1);
}
const critters = new Critters({
path: DIST,
preload: 'default',
noscriptFallback: true,
pruneSource: false,
logLevel: 'warn',
});
const files = fs.readdirSync(DIST).filter((f) => f.endsWith('.html'));
for (const file of files) {
const filePath = path.join(DIST, file);
let html = fs.readFileSync(filePath, 'utf8');
html = await critters.process(html);
// html = postProcessSwapLinks(html);
fs.writeFileSync(filePath, html, 'utf8');
console.log('✓ Critical CSS inlined → dist/' + file);
}
console.log('Critical CSS step complete.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Post-build: minify all JS in dist/assets/js/ (static scripts copied from static/assets/js/).
* Runs after vite build (and optionally after critters). Uses esbuild for minification.
*/
import * as esbuild from 'esbuild';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const JS_DIR = path.join(ROOT, 'dist', 'assets', 'js');
async function main() {
if (!fs.existsSync(JS_DIR)) {
console.warn('dist/assets/js/ not found; skipping static JS minify.');
return;
}
const files = fs.readdirSync(JS_DIR).filter((f) => f.endsWith('.js'));
if (files.length === 0) {
console.warn('No .js files in dist/assets/js/; skipping.');
return;
}
for (const file of files) {
const filePath = path.join(JS_DIR, file);
const code = fs.readFileSync(filePath, 'utf8');
const result = await esbuild.transform(code, {
minify: true,
target: 'es2015',
});
fs.writeFileSync(filePath, result.code, 'utf8');
console.log('✓ Minified dist/assets/js/' + file);
}
console.log('Static JS minify complete.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

42
scripts/update-e2e-snapshots.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Regenerate Playwright visual regression snapshots.
# - In the devcontainer (Playwright Noble): same image as CI, snapshot matches CI.
# - When Docker is available on host: runs in the same image as CI for a CI-accurate baseline.
# - Otherwise: run the update-e2e-snapshots workflow in Woodpecker (manual pipeline).
set -e
SCRIPT_DIR="${BASH_SOURCE%/*}"
PROJECT_ROOT="${SCRIPT_DIR}/.."
cd "$PROJECT_ROOT"
if command -v docker >/dev/null 2>&1; then
PLAYWRIGHT_IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.58.0-noble}"
echo "Using Docker image: $PLAYWRIGHT_IMAGE (same as CI)"
echo "Project root: $PROJECT_ROOT"
echo ""
docker run --rm \
-v "$PROJECT_ROOT:/app" -w /app \
-e CI=1 \
"$PLAYWRIGHT_IMAGE" \
bash -c '
corepack enable && corepack prepare pnpm@10.28.2 --activate
pnpm install --frozen-lockfile || pnpm install
pnpm run build
npx serve dist -p 4173 &
sleep 2
pnpm exec playwright test --update-snapshots
'
else
echo "Updating snapshots in the current environment (matches CI when using the devcontainer)."
echo ""
pnpm run build
# Unset CI so Playwright config starts the preview server
unset CI
pnpm exec playwright test --update-snapshots
fi
echo ""
echo "Snapshots updated. Commit the changed files under tests/visual.spec.ts-snapshots/ if needed."

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,15 +0,0 @@
<svg width="100%" height="100%" viewBox="0 0 2134 2134" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<style>
.bg { fill: #0b0b0f; }
.fg { fill: #fff; }
@media (prefers-color-scheme: dark) {
.bg { fill: #f2f2f2; }
.fg { fill: #0b0b0f; }
}
</style>
<path class="bg" d="M2133.333,400l0,1333.333c0,220.766 -179.234,400 -400,400l-1333.333,0c-220.766,0 -400,-179.234 -400,-400l0,-1333.333c0,-220.766 179.234,-400 400,-400l1333.333,0c220.766,0 400,179.234 400,400Z"/>
<g>
<path class="fg" d="M434.867,1513.667l0,-652.2l148.5,0l0,150.3l-17.1,-24.3c11.6,-47.2 35.55,-82.4 71.85,-105.6c36.3,-23.2 78.85,-34.8 127.65,-34.8c53.2,0 100.25,13.85 141.15,41.55c40.9,27.7 67.35,64.55 79.35,110.55l-45,3.9c20.2,-52.6 50.25,-91.75 90.15,-117.45c39.9,-25.7 85.95,-38.55 138.15,-38.55c46.2,0 87.5,10.4 123.9,31.2c36.4,20.8 65.15,49.7 86.25,86.7c21.1,37 31.65,79.8 31.65,128.4l0,420.3l-157.5,0l0,-382.8c0,-29 -5.2,-53.8 -15.6,-74.4c-10.4,-20.6 -24.9,-36.65 -43.5,-48.15c-18.6,-11.5 -40.8,-17.25 -66.6,-17.25c-25,0 -47.05,5.75 -66.15,17.25c-19.1,11.5 -33.9,27.6 -44.4,48.3c-10.5,20.7 -15.75,45.45 -15.75,74.25l0,382.8l-157.5,0l0,-382.8c0,-29 -5.2,-53.8 -15.6,-74.4c-10.4,-20.6 -24.9,-36.65 -43.5,-48.15c-18.6,-11.5 -40.8,-17.25 -66.6,-17.25c-25.2,0 -47.3,5.75 -66.3,17.25c-19,11.5 -33.75,27.6 -44.25,48.3c-10.5,20.7 -15.75,45.45 -15.75,74.25l0,382.8l-157.5,0Z" style="fill-rule:nonzero;"/>
<path class="fg" d="M1540.967,1513.667l0,-652.2l157.5,0l0,652.2l-157.5,0Zm0,-726l0,-168l157.5,0l0,168l-157.5,0Z" style="fill-rule:nonzero;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,768 +0,0 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mifi Ventures — Software Engineering Consulting | Boston, MA</title>
<meta
name="description"
content="Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications. Specializing in frontend architecture, performance optimization, and modern web development."
/>
<link rel="canonical" href="https://mifi.ventures/" />
<link
rel="preload"
href="/assets/fonts/fraunces-v38-latin-600.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/fraunces-v38-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-italic.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-500.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-600.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<meta
name="robots"
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
/>
<meta name="author" content="Mike Fitzpatrick" />
<meta name="geo.region" content="US-MA" />
<meta name="geo.placename" content="Boston" />
<meta name="geo.position" content="42.360082;-71.058880" />
<meta name="ICBM" content="42.360082, -71.058880" />
<!-- Theme Color -->
<meta
name="theme-color"
content="#0052cc"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#4da6ff"
media="(prefers-color-scheme: dark)"
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://mifi.ventures/" />
<meta property="og:site_name" content="mifi Ventures" />
<meta
property="og:title"
content="mifi Ventures — Software Engineering Consulting | Boston, MA"
/>
<meta
property="og:description"
content="Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications. Specializing in frontend architecture, performance optimization, and modern web development."
/>
<meta
property="og:image"
content="https://mifi.ventures/assets/og-image.png"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
property="og:image:alt"
content="mifi Ventures — Software Engineering Consulting"
/>
<meta property="og:locale" content="en_US" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://mifi.ventures/" />
<meta
name="twitter:title"
content="mifi Ventures — Software Engineering Consulting | Boston, MA"
/>
<meta
name="twitter:description"
content="Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications. Specializing in frontend architecture, performance optimization, and modern web development."
/>
<meta
name="twitter:image"
content="https://mifi.ventures/assets/og-image.png"
/>
<meta
name="twitter:image:alt"
content="mifi Ventures — Software Engineering Consulting"
/>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<!-- Styles -->
<link rel="stylesheet" href="/styles.css" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://mifi.ventures/#organization",
"name": "mifi Ventures, LLC",
"legalName": "mifi Ventures, LLC",
"url": "https://mifi.ventures/",
"logo": {
"@type": "ImageObject",
"url": "https://mifi.ventures/favicon.svg"
},
"description": "Software engineering consulting specializing in product-focused frontend architecture, performance optimization, and accessibility-first engineering.",
"founder": {
"@id": "https://mifi.ventures/#principal"
},
"address": {
"@type": "PostalAddress",
"addressLocality": "Boston",
"addressRegion": "MA",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 42.360082,
"longitude": -71.05888
},
"areaServed": {
"@type": "Country",
"name": "United States"
},
"hasOfferCatalog": {
"@id": "https://mifi.ventures/#services"
},
"sameAs": [
"https://www.linkedin.com/in/the-mifi",
"https://github.com/the-mifi"
]
},
{
"@type": "Person",
"@id": "https://mifi.ventures/#principal",
"name": "Mike Fitzpatrick",
"jobTitle": "Principal Software Engineer and Architect",
"description": "Senior full-stack engineer and architect helping teams ship reliable, accessible, high-performance web products.",
"url": "https://mifi.ventures/",
"worksFor": {
"@id": "https://mifi.ventures/#organization"
},
"knowsAbout": [
"Frontend Architecture",
"UI Architecture",
"React Development",
"Web Performance Optimization",
"Core Web Vitals",
"Technical SEO",
"Web Accessibility (WCAG)",
"Component Libraries",
"Design Systems",
"JavaScript",
"TypeScript",
"Modern Web Development",
"Greenfield Product Development",
"Legacy System Modernization",
"Code Refactoring"
],
"sameAs": [
"https://www.linkedin.com/in/the-mifi",
"https://github.com/the-mifi"
]
},
{
"@type": "WebSite",
"@id": "https://mifi.ventures/#website",
"url": "https://mifi.ventures/",
"name": "mifi Ventures",
"description": "Software Engineering Consulting — Boston, MA",
"publisher": {
"@id": "https://mifi.ventures/#organization"
},
"potentialAction": {
"@type": "ReserveAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://cal.mifi.ventures/the-mifi"
},
"name": "Schedule a 30-minute intro call"
}
},
{
"@type": "WebPage",
"@id": "https://mifi.ventures/#webpage",
"url": "https://mifi.ventures/",
"name": "mifi Ventures — Software Engineering Consulting | Boston, MA",
"description": "Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications.",
"isPartOf": {
"@id": "https://mifi.ventures/#website"
},
"about": {
"@id": "https://mifi.ventures/#organization"
},
"mainEntity": {
"@id": "https://mifi.ventures/#organization"
},
"primaryImageOfPage": {
"@type": "ImageObject",
"url": "https://mifi.ventures/favicon.svg"
},
"inLanguage": "en-US"
},
{
"@type": "OfferCatalog",
"@id": "https://mifi.ventures/#services",
"name": "Software Engineering Consulting Services",
"description": "Consulting services offered by mifi Ventures",
"numberOfItems": 6,
"itemListElement": [
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Frontend and UI Architecture",
"description": "Product-focused frontend and UI architecture for modern web applications, with an emphasis on clarity, scalability, and long-term maintainability."
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Greenfield Product Development",
"description": "Greenfield product builds and early-stage foundations, getting new projects off the ground quickly with structures designed to grow, not be rewritten."
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Performance Optimization",
"description": "Performance, Core Web Vitals, rendering strategy, and technical SEO optimization focused on real-world user journeys."
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Accessibility Engineering",
"description": "Accessibility-first engineering, ensuring WCAG-compliant interfaces with semantic markup, keyboard parity, and inclusive interaction patterns."
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "System Modernization",
"description": "Modernization and stabilization of existing systems, including refactors, framework upgrades, and untangling overgrown frontend codebases."
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "End-to-End Feature Delivery",
"description": "End-to-end feature delivery with clear ownership and documentation, spanning frontend and supporting backend work without unnecessary complexity."
}
}
]
}
]
}
</script>
</head>
<body>
<!-- Skip to main content link for keyboard users -->
<a href="#main" class="skip-link">Skip to main content</a>
<!-- Header + Hero Section -->
<header id="header" class="hero">
<div class="container">
<h1 class="logo">
<svg
width="100%"
height="100%"
viewBox="0 0 3934 513"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
>
<g>
<path
fill="currentColor"
d="M0,504.667l0,-362.333l82.5,0l0,83.5l-9.5,-13.5c6.444,-26.222 19.75,-45.778 39.917,-58.667c20.167,-12.889 43.806,-19.333 70.917,-19.333c29.556,0 55.694,7.694 78.417,23.083c22.722,15.389 37.417,35.861 44.083,61.417l-25,2.167c11.222,-29.222 27.917,-50.972 50.083,-65.25c22.167,-14.278 47.75,-21.417 76.75,-21.417c25.667,0 48.611,5.778 68.833,17.333c20.222,11.556 36.194,27.611 47.917,48.167c11.722,20.556 17.583,44.333 17.583,71.333l0,233.5l-87.5,0l0,-212.667c0,-16.111 -2.889,-29.889 -8.667,-41.333c-5.778,-11.444 -13.833,-20.361 -24.167,-26.75c-10.333,-6.389 -22.667,-9.583 -37,-9.583c-13.889,0 -26.139,3.194 -36.75,9.583c-10.611,6.389 -18.833,15.333 -24.667,26.833c-5.833,11.5 -8.75,25.25 -8.75,41.25l0,212.667l-87.5,0l0,-212.667c0,-16.111 -2.889,-29.889 -8.667,-41.333c-5.778,-11.444 -13.833,-20.361 -24.167,-26.75c-10.333,-6.389 -22.667,-9.583 -37,-9.583c-14,0 -26.278,3.194 -36.833,9.583c-10.556,6.389 -18.75,15.333 -24.583,26.833c-5.833,11.5 -8.75,25.25 -8.75,41.25l0,212.667l-87.5,0Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M614.5,504.667l0,-362.333l87.5,0l0,362.333l-87.5,0Zm0,-403.333l0,-93.333l87.5,0l0,93.333l-87.5,0Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M823.5,504.667l0,-284.833l-63.833,0l0,-77.5l63.833,0l0,-12c0,-27.889 5.639,-51.472 16.917,-70.75c11.278,-19.278 27.194,-34.028 47.75,-44.25c20.556,-10.222 44.778,-15.333 72.667,-15.333c5.444,0 11.389,0.333 17.833,1c6.444,0.667 11.778,1.444 16,2.333l0,75.333c-4.111,-0.889 -8.083,-1.444 -11.917,-1.667c-3.833,-0.222 -7.361,-0.333 -10.583,-0.333c-19.333,0 -34.361,4.361 -45.083,13.083c-10.722,8.722 -16.083,22.25 -16.083,40.583l0,12l158.667,0l0,77.5l-158.667,0l0,284.833l-87.5,0Zm213.667,0l0,-362.333l87.5,0l0,362.333l-87.5,0Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M1474.74,50.297c0,-3.892 1.365,-6.915 4.096,-9.068c2.731,-2.153 6.86,-3.229 12.388,-3.229l106.333,0c5.83,0 10.083,1.052 12.76,3.156c2.677,2.104 4.016,5.056 4.016,8.854c0,2.844 -0.913,5.219 -2.74,7.125c-1.826,1.906 -5.043,3.74 -9.651,5.5l-13.286,4.479c-6.694,2.681 -12.347,6.961 -16.958,12.841c-4.611,5.88 -8.964,15.216 -13.057,28.008l-114.453,337.807c-2.128,6.608 -3.694,12.082 -4.695,16.424c-1.002,4.342 -1.503,8.846 -1.503,13.513l0,15.141c0,4.351 -1.242,7.741 -3.727,10.172c-2.484,2.431 -5.817,3.646 -9.997,3.646l-71.854,0c-4.354,0 -7.762,-1.215 -10.224,-3.646c-2.462,-2.431 -3.693,-5.965 -3.693,-10.604l0,-14.969c0,-3.542 -0.508,-7.257 -1.523,-11.146c-1.016,-3.889 -2.428,-8.453 -4.237,-13.693l-125.854,-363.062c-2.097,-6.191 -4.4,-10.66 -6.909,-13.406c-2.509,-2.747 -5.987,-4.977 -10.435,-6.693l-14.141,-4.193c-7.497,-2.809 -11.245,-7.128 -11.245,-12.958c0,-3.892 1.394,-6.915 4.182,-9.068c2.788,-2.153 7.002,-3.229 12.641,-3.229l151.687,0c5.75,0 9.968,1.076 12.654,3.229c2.686,2.153 4.029,5.175 4.029,9.068c0,3.128 -1.044,5.646 -3.133,7.552c-2.089,1.906 -5.221,3.55 -9.398,4.932l-25.328,4.427c-5.542,1.573 -8.939,4.237 -10.193,7.992c-1.253,3.755 -0.444,9.841 2.427,18.258l124.146,361.656l-26.328,20.599l125.198,-369.797c3.542,-10.618 4.107,-18.966 1.695,-25.044c-2.411,-6.078 -9.02,-10.76 -19.826,-14.044l-21.573,-4.193c-3.972,-1.382 -7.014,-2.978 -9.125,-4.789c-2.111,-1.811 -3.167,-4.327 -3.167,-7.549Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M1905.721,311.365c0,9.878 -2.872,17.47 -8.615,22.776c-5.743,5.306 -14.118,7.958 -25.125,7.958l-210,0l0,-21.203l147.052,0c10.229,0 15.344,-4.622 15.344,-13.865c0,-30.597 -5.845,-54.022 -17.536,-70.273c-11.691,-16.252 -27.196,-24.378 -46.516,-24.378c-15.257,0 -28.655,4.508 -40.195,13.523c-11.54,9.016 -20.561,21.96 -27.062,38.833c-6.502,16.873 -9.753,37.037 -9.753,60.492c0,43.872 10.266,76.974 30.799,99.307c20.533,22.333 47.546,33.5 81.039,33.5c21.031,0 39.325,-4.677 54.88,-14.031c15.556,-9.354 26.748,-22.125 33.578,-38.313c2.969,-3.653 5.411,-6.146 7.326,-7.479c1.915,-1.333 3.937,-2 6.065,-2c2.938,0 5.082,1.291 6.432,3.872c1.351,2.582 1.97,5.721 1.859,9.419c-1.174,18.809 -7.769,36.038 -19.786,51.688c-12.017,15.649 -28.122,28.109 -48.315,37.38c-20.193,9.271 -43.218,13.906 -69.076,13.906c-31.069,0 -58.526,-6.501 -82.37,-19.503c-23.844,-13.002 -42.481,-31.285 -55.911,-54.849c-13.431,-23.564 -20.146,-51.166 -20.146,-82.805c0,-32.92 6.497,-62.108 19.49,-87.562c12.993,-25.455 31.546,-45.48 55.659,-60.076c24.113,-14.595 52.768,-21.893 85.966,-21.893c27.938,-0 51.99,5.361 72.159,16.083c20.168,10.722 35.67,25.512 46.505,44.37c10.835,18.858 16.253,40.564 16.253,65.12Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M2076.711,205.234l0,253.599c0,6.017 0.997,10.479 2.99,13.385c1.993,2.906 4.998,4.97 9.016,6.193l14.234,3.453c6.413,2.337 9.62,6.03 9.62,11.078c0,7.816 -5.082,11.724 -15.245,11.724l-124.286,0c-5.035,0 -8.755,-1.004 -11.161,-3.013c-2.406,-2.009 -3.609,-4.721 -3.609,-8.138c0,-2.733 0.865,-5.056 2.596,-6.969c1.731,-1.913 4.438,-3.458 8.122,-4.635l15.234,-3.5c4.031,-1.222 7.04,-3.262 9.026,-6.12c1.986,-2.858 2.979,-7.281 2.979,-13.271l0,-204.677c0,-4.844 -0.782,-8.342 -2.346,-10.495c-1.564,-2.153 -4.126,-3.467 -7.685,-3.943l-20.542,-1c-3.559,-0.667 -6.109,-1.819 -7.651,-3.456c-1.542,-1.637 -2.312,-3.734 -2.312,-6.289c0,-2.972 0.918,-5.387 2.753,-7.245c1.835,-1.858 5.192,-3.66 10.07,-5.406l62.24,-21.5c6.941,-2.556 12.56,-4.39 16.857,-5.503c4.297,-1.113 8.263,-1.669 11.898,-1.669c5.688,0 9.977,1.576 12.867,4.729c2.891,3.153 4.336,7.375 4.336,12.667Zm-9.385,71.365l-12.724,-13.036l13.526,-11.927c26.417,-23.639 49.119,-40.515 68.107,-50.628c18.988,-10.113 37.15,-15.169 54.487,-15.169c26.26,0 46.657,8.724 61.19,26.172c14.533,17.448 23.444,41.141 26.732,71.078l20.208,174.552c0.729,6.417 2.038,11.217 3.927,14.401c1.889,3.184 4.993,5.387 9.313,6.609l13.427,3.26c3.684,1.16 6.391,2.697 8.122,4.612c1.731,1.915 2.596,4.246 2.596,6.992c0,3.417 -1.175,6.129 -3.526,8.138c-2.351,2.009 -6.115,3.013 -11.292,3.013l-125.594,0c-10.198,0 -15.297,-3.908 -15.297,-11.724c-0,-5.017 3.177,-8.71 9.531,-11.078l14.896,-3.453c4.448,-1.222 7.823,-3.425 10.125,-6.609c2.302,-3.184 3.087,-7.905 2.354,-14.161l-18.995,-162.974c-2.476,-20.635 -7.75,-36.081 -15.823,-46.336c-8.073,-10.255 -19.927,-15.383 -35.562,-15.383c-9.844,0 -20.199,2.655 -31.065,7.966c-10.866,5.311 -22.546,13.293 -35.039,23.945l-13.625,11.74Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M2387.845,220.714l-18.271,-4.667c-4.927,-1.479 -8.352,-3.2 -10.273,-5.161c-1.922,-1.962 -2.883,-4.276 -2.883,-6.943c0,-3.51 1.223,-6.199 3.669,-8.065c2.446,-1.866 5.704,-2.799 9.773,-2.799l21.99,0c5.101,-0 9.294,-0.87 12.581,-2.609c3.286,-1.74 6.416,-4.936 9.388,-9.589l34.552,-51.276c3.59,-4.91 7.104,-8.504 10.542,-10.784c3.438,-2.28 6.943,-3.419 10.516,-3.419c3.878,0 6.89,1.22 9.034,3.659c2.144,2.439 3.216,5.905 3.216,10.398l0,288.479c0,15.747 3.147,27.707 9.44,35.88c6.293,8.174 15.034,12.26 26.221,12.26c7.67,0 13.736,-1.373 18.198,-4.12c4.462,-2.747 8.049,-6.002 10.763,-9.766c2.714,-3.764 5.304,-7.236 7.771,-10.417c2.467,-3.181 5.447,-5.205 8.94,-6.073c2.733,-0.177 4.901,0.624 6.505,2.404c1.604,1.78 2.375,4.773 2.312,8.982c-0.507,11.427 -4.431,21.975 -11.773,31.643c-7.342,9.668 -17.384,17.448 -30.125,23.339c-12.741,5.891 -27.367,8.836 -43.878,8.836c-26.191,0 -46.827,-6.628 -61.909,-19.883c-15.082,-13.255 -22.622,-33.345 -22.622,-60.268l0,-192.13c0,-5.128 -1.021,-9.007 -3.063,-11.635c-2.042,-2.628 -5.58,-4.72 -10.615,-6.276Zm61.109,-0.854l0.281,-26.781l106.25,0c4.444,-0 7.866,0.858 10.266,2.573c2.399,1.715 3.599,4.257 3.599,7.625c0,4.733 -2.383,8.68 -7.148,11.841c-4.766,3.161 -12.326,4.742 -22.68,4.742l-90.568,0Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M2855.8,465.307l0,-21.885l-2.146,-1.526l0,-187.313c0,-4.847 -0.782,-8.346 -2.346,-10.497c-1.564,-2.151 -4.126,-3.464 -7.685,-3.94l-20.542,-1.005c-3.559,-0.667 -6.109,-1.818 -7.648,-3.453c-1.54,-1.635 -2.31,-3.733 -2.31,-6.292c0,-2.969 0.917,-5.382 2.75,-7.24c1.833,-1.858 5.189,-3.661 10.068,-5.411l62.24,-21.495c6.924,-2.559 12.538,-4.394 16.844,-5.505c4.306,-1.111 8.276,-1.667 11.911,-1.667c5.687,0 9.977,1.576 12.867,4.729c2.891,3.153 4.336,7.373 4.336,12.661l0,253.365c0,6.017 0.997,10.503 2.99,13.456c1.993,2.953 4.998,4.994 9.016,6.122l14.516,3.312c3.812,1.16 6.609,2.701 8.388,4.622c1.78,1.922 2.669,4.312 2.669,7.169c0,3.417 -1.223,6.129 -3.669,8.138c-2.446,2.009 -6.258,3.013 -11.435,3.013l-65.932,0c-10.229,0 -18.6,-3.578 -25.112,-10.734c-6.512,-7.156 -9.768,-16.698 -9.768,-28.625Zm-208.76,-50.839l0,-159.885c0,-4.847 -0.786,-8.346 -2.357,-10.497c-1.571,-2.151 -4.143,-3.464 -7.716,-3.94l-20.547,-1.005c-3.559,-0.667 -6.109,-1.818 -7.648,-3.453c-1.54,-1.635 -2.31,-3.733 -2.31,-6.292c0,-2.969 0.918,-5.382 2.753,-7.24c1.835,-1.858 5.19,-3.661 10.065,-5.411l62.286,-21.495c7.226,-2.653 12.964,-4.511 17.214,-5.576c4.25,-1.064 7.908,-1.596 10.974,-1.596c5.972,0 10.428,1.576 13.367,4.729c2.939,3.153 4.409,7.373 4.409,12.661l0,197.422c0,20.92 5.023,36.531 15.07,46.833c10.047,10.302 23.452,15.453 40.216,15.453c10.382,0 21.431,-2.572 33.146,-7.716c11.715,-5.144 23.986,-13.207 36.813,-24.19l13.62,-11.74l12.724,13.031l-13.526,11.927c-26.653,24.323 -49.98,41.37 -69.982,51.141c-20.002,9.771 -38.973,14.656 -56.914,14.656c-27.354,0 -49.469,-8.744 -66.344,-26.232c-16.875,-17.488 -25.313,-41.35 -25.313,-71.586Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M3130.82,330.943c0,-31.67 4.576,-58.287 13.729,-79.852c9.153,-21.564 21.071,-37.831 35.755,-48.799c14.684,-10.969 30.319,-16.453 46.906,-16.453c20.118,0 35.681,5.683 46.69,17.049c11.009,11.366 16.513,27.369 16.513,48.008c0,17.24 -3.655,30.185 -10.964,38.836c-7.309,8.651 -16.743,12.977 -28.302,12.977c-11.556,0 -20.418,-3.171 -26.586,-9.513c-6.168,-6.342 -9.284,-15.235 -9.346,-26.68l-0.047,-11.573c-0.111,-7.236 -1.802,-12.64 -5.073,-16.211c-3.271,-3.571 -8.644,-5.357 -16.12,-5.357c-8.635,0 -16.97,3.531 -25.003,10.594c-8.033,7.062 -14.597,17.733 -19.693,32.01c-5.095,14.278 -7.643,32.392 -7.643,54.344l-10.818,0.62Zm6.812,-125.427l4.005,81.208l0,171.87c0,5.497 1.199,9.661 3.596,12.495c2.398,2.833 6.598,4.719 12.602,5.656l29.714,4.427c4.462,0.701 7.769,2.029 9.922,3.982c2.153,1.953 3.229,4.694 3.229,8.221c0,3.51 -1.299,6.27 -3.896,8.279c-2.597,2.009 -6.398,3.013 -11.401,3.013l-147.318,0c-5.083,0 -8.836,-1.013 -11.258,-3.039c-2.422,-2.026 -3.633,-4.739 -3.633,-8.138c0,-2.747 0.885,-5.085 2.656,-7.016c1.771,-1.931 4.514,-3.483 8.229,-4.656l15.068,-3.406c4.035,-1.128 7.044,-3.137 9.029,-6.026c1.984,-2.889 2.977,-7.295 2.977,-13.219l0,-204.391c0,-4.861 -0.779,-8.379 -2.336,-10.555c-1.557,-2.175 -4.114,-3.503 -7.669,-3.982l-20.667,-1c-3.51,-0.667 -6.04,-1.818 -7.589,-3.453c-1.549,-1.635 -2.323,-3.724 -2.323,-6.266c0,-2.955 0.942,-5.393 2.826,-7.315c1.884,-1.922 5.232,-3.709 10.044,-5.362l61.26,-20.76c8.799,-3.306 15.307,-5.466 19.526,-6.482c4.219,-1.016 7.575,-1.523 10.068,-1.523c4.08,0 7.156,1.345 9.229,4.036c2.073,2.691 3.443,7.158 4.109,13.401Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M3622.771,311.365c0,9.878 -2.872,17.47 -8.615,22.776c-5.743,5.306 -14.118,7.958 -25.125,7.958l-210,0l0,-21.203l147.052,0c10.229,0 15.344,-4.622 15.344,-13.865c0,-30.597 -5.845,-54.022 -17.536,-70.273c-11.691,-16.252 -27.196,-24.378 -46.516,-24.378c-15.257,0 -28.655,4.508 -40.195,13.523c-11.54,9.016 -20.561,21.96 -27.063,38.833c-6.502,16.873 -9.753,37.037 -9.753,60.492c0,43.872 10.266,76.974 30.799,99.307c20.533,22.333 47.546,33.5 81.039,33.5c21.031,0 39.325,-4.677 54.88,-14.031c15.556,-9.354 26.748,-22.125 33.578,-38.313c2.969,-3.653 5.411,-6.146 7.326,-7.479c1.915,-1.333 3.937,-2 6.065,-2c2.938,0 5.082,1.291 6.432,3.872c1.351,2.582 1.97,5.721 1.859,9.419c-1.174,18.809 -7.769,36.038 -19.786,51.688c-12.017,15.649 -28.122,28.109 -48.315,37.38c-20.193,9.271 -43.218,13.906 -69.076,13.906c-31.069,0 -58.526,-6.501 -82.37,-19.503c-23.844,-13.002 -42.481,-31.285 -55.911,-54.849c-13.431,-23.564 -20.146,-51.166 -20.146,-82.805c0,-32.92 6.497,-62.108 19.49,-87.562c12.993,-25.455 31.546,-45.48 55.659,-60.076c24.113,-14.595 52.768,-21.893 85.966,-21.893c27.938,-0 51.99,5.361 72.159,16.083c20.168,10.722 35.67,25.512 46.505,44.37c10.835,18.858 16.253,40.564 16.253,65.12Z"
style="fill-rule: nonzero"
/>
<path
fill="currentColor"
d="M3813.044,487.698c15.937,0 28.422,-4.111 37.453,-12.333c9.031,-8.222 13.547,-18.745 13.547,-31.568c0,-8.125 -1.861,-15.469 -5.583,-22.031c-3.722,-6.562 -10.543,-12.562 -20.464,-17.997c-9.92,-5.436 -24.125,-10.471 -42.615,-15.107c-31.625,-7.188 -56.268,-15.988 -73.93,-26.401c-17.661,-10.413 -30.004,-22.363 -37.029,-35.849c-7.024,-13.486 -10.536,-28.356 -10.536,-44.609c0,-29.288 10.358,-52.604 31.073,-69.948c20.715,-17.344 50.299,-26.016 88.75,-26.016c14.302,0 26.049,1.234 35.24,3.703c9.191,2.469 16.712,4.961 22.562,7.477c5.851,2.516 10.873,3.773 15.068,3.773c4.382,0 7.961,-1.258 10.737,-3.773c2.776,-2.516 5.492,-5.031 8.148,-7.547c2.656,-2.516 5.993,-3.773 10.01,-3.773c2.781,0 5.272,0.905 7.471,2.716c2.2,1.811 4.03,5.162 5.492,10.055l22.667,71.646c2.066,6.226 2.702,11.364 1.909,15.414c-0.793,4.05 -3.287,6.918 -7.482,8.602c-4.097,1.556 -7.667,1.551 -10.708,-0.013c-3.042,-1.564 -5.937,-4.451 -8.687,-8.659c-10.062,-18.646 -20.847,-33.42 -32.354,-44.323c-11.507,-10.903 -23.586,-18.714 -36.237,-23.435c-12.651,-4.72 -25.85,-7.081 -39.596,-7.081c-19.764,0 -34.641,4.182 -44.633,12.547c-9.991,8.365 -14.987,19.563 -14.987,33.594c0,8.41 2.122,16.051 6.367,22.924c4.245,6.873 12.054,13.202 23.427,18.987c11.373,5.785 27.584,11.288 48.633,16.51c27.465,6.378 49.29,14.37 65.474,23.974c16.184,9.604 27.82,21.081 34.909,34.43c7.089,13.349 10.633,28.883 10.633,46.602c0,18.205 -4.623,34.268 -13.87,48.19c-9.247,13.922 -22.141,24.768 -38.682,32.539c-16.542,7.771 -35.79,11.656 -57.745,11.656c-13.83,0 -25.069,-1.468 -33.719,-4.404c-8.649,-2.936 -15.788,-5.848 -21.417,-8.737c-5.628,-2.889 -10.825,-4.333 -15.589,-4.333c-4.257,0 -7.92,1.424 -10.99,4.273c-3.069,2.849 -6.016,5.722 -8.841,8.62c-2.825,2.898 -6.07,4.346 -9.737,4.346c-2.701,0 -5.029,-0.989 -6.982,-2.966c-1.953,-1.977 -3.39,-5.31 -4.31,-9.997l-13.906,-67.13c-1.462,-7.559 -1.775,-13.197 -0.94,-16.914c0.835,-3.717 3.119,-6.322 6.852,-7.815c3.986,-1.587 7.492,-1.376 10.518,0.633c3.026,2.009 6.143,5.641 9.352,10.898c13.649,24.583 28.663,42.171 45.042,52.763c16.378,10.592 33.123,15.888 50.234,15.888Z"
style="fill-rule: nonzero"
/>
</g>
</svg>
<span class="sr-only">mifi Ventures</span>
</h1>
<p class="headline">Software Engineering Consulting</p>
<p class="subhead">
Principal: Mike Fitzpatrick — senior full-stack engineer and architect
helping teams ship reliable, accessible, high-performance web
products.
</p>
<div class="cta-group">
<a
href="https://cal.mifi.ventures/the-mifi"
class="btn btn-primary"
target="_blank"
rel="noopener noreferrer"
aria-label="Schedule a 30-minute intro call (opens in new tab)"
>
Schedule a 30-minute intro call
</a>
<a
href="/assets/resume.pdf"
class="btn btn-secondary"
download
aria-label="Download Mike Fitzpatrick's resume as PDF"
>
Download resume
</a>
</div>
</div>
</header>
<!-- Main Content -->
<main id="main">
<!-- Experience Includes Section -->
<section
id="experience"
class="section experience-section"
aria-labelledby="experience-heading"
>
<div class="container">
<h2 id="experience-heading" class="section-title">
Experience includes teams at:
</h2>
<!-- Logo strip with accessible images -->
<div class="logo-strip" role="list" aria-label="Company logos">
<div class="logo-item" role="listitem">
<img
src="/assets/logos/atlassian.svg"
alt="Atlassian"
loading="lazy"
width="2500"
height="2500"
/>
<span class="logo-fallback-text">Atlassian</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/tjx.svg"
alt="TJ Maxx (The TJX Companies)"
loading="lazy"
width="2500"
height="621"
/>
<span class="logo-fallback-text">TJ Maxx</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/cargurus.svg"
alt="CarGurus"
loading="lazy"
width="2500"
height="398"
/>
<span class="logo-fallback-text">CarGurus</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/timberland.svg"
alt="Timberland"
loading="lazy"
width="190"
height="35"
/>
<span class="logo-fallback-text">Timberland</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/vf.svg"
alt="VF Corporation"
loading="lazy"
width="190"
height="155"
/>
<span class="logo-fallback-text">VF Corporation</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/bottomline.svg"
alt="Bottomline Technologies"
loading="lazy"
width="2702"
height="571"
/>
<span class="logo-fallback-text">Bottomline Technologies</span>
</div>
<div class="logo-item" role="listitem">
<img
src="/assets/logos/mfa-boston.svg"
alt="Museum of Fine Arts Boston"
loading="lazy"
width="572"
height="88"
/>
<span class="logo-fallback-text">MFA Boston</span>
</div>
</div>
<!-- Text-only fallback list (visible on very small screens or when images fail) -->
<ul class="logo-text-list" aria-hidden="true">
<li>Atlassian</li>
<li>TJ Maxx (The TJX Companies)</li>
<li>CarGurus</li>
<li>Timberland</li>
<li>VF Corporation</li>
<li>Bottomline Technologies</li>
<li>Museum of Fine Arts Boston</li>
</ul>
<p class="footnote">
Logos are trademarks of their respective owners.
</p>
</div>
</section>
<!-- What We Do Section -->
<section
id="what-we-do"
class="section"
aria-labelledby="what-we-do-heading"
>
<div class="container">
<h2 id="what-we-do-heading" class="section-title">What We Do</h2>
<ul class="content-list">
<li>
Product-focused frontend and UI architecture for modern web
applications, with an emphasis on clarity, scalability, and
long-term maintainability.
</li>
<li>
Greenfield product builds and early-stage foundations, getting new
projects off the ground quickly with structures designed to grow,
not be rewritten.
</li>
<li>
Performance, Core Web Vitals, rendering strategy, and technical
SEO optimization focused on real-world user journeys—not just lab
scores.
</li>
<li>
Accessibility-first engineering, ensuring WCAG-compliant
interfaces with semantic markup, keyboard parity, and inclusive
interaction patterns.
</li>
<li>
Modernization and stabilization of existing systems, including
refactors, framework upgrades, and untangling overgrown frontend
codebases.
</li>
<li>
End-to-end feature delivery with clear ownership and
documentation, spanning frontend and supporting backend work
without unnecessary complexity.
</li>
</ul>
</div>
</section>
<!-- Selected Impact Section -->
<section id="impact" class="section" aria-labelledby="impact-heading">
<div class="container">
<h2 id="impact-heading" class="section-title">Selected Impact</h2>
<ul class="content-list">
<li>
Get new products off the ground quickly by establishing durable
frontend and platform foundations—clean architecture, clear
patterns, and pragmatic defaults designed to scale with teams and
traffic.
</li>
<li>
Improve performance, Core Web Vitals, and technical SEO on
high-traffic user journeys through rendering strategy, bundle
discipline, and careful attention to real-world loading behavior.
</li>
<li>
Build accessibility into core UI systems, not as a
retrofit—semantic markup, keyboard parity, and screen reader
support baked into reusable components and design patterns.
</li>
<li>
Bring order to complex or aging codebases by simplifying
structure, reducing duplication, and clarifying ownership,
enabling teams to ship confidently without over-engineering.
</li>
<li>
Design and evolve shared component libraries and UI systems that
improve consistency, velocity, and long-term maintainability
across multiple teams.
</li>
<li>
Partner closely with product, design, and engineering leadership
(including marketing teams and non-technical organizations) to
translate goals into shippable systems, balancing speed, quality,
and technical risk.
</li>
</ul>
</div>
</section>
<!-- How We Work Section -->
<section
id="how-we-work"
class="section"
aria-labelledby="how-we-work-heading"
>
<div class="container">
<h2 id="how-we-work-heading" class="section-title">How We Work</h2>
<ul class="content-list">
<li>
Engagements are consulting-led and senior-driven. I work directly
with founders, product leaders, marketing teams, and engineering
teams—including organizations without in-house technical staff—to
establish direction and deliver solutions with a high degree of
autonomy.
</li>
<li>
Focused, pragmatic scope. Work is scoped to deliver real progress
quickly, with an emphasis on building the right foundation rather
than over-engineering for hypothetical futures.
</li>
<li>
Async-friendly, low-friction communication. Clear written updates,
documented decisions, and scheduled calls when they add value—not
meetings for their own sake.
</li>
<li>
Quality as a default. Accessibility, performance, and
maintainability are built into the work from the start, not added
later as cleanup.
</li>
<li>
Flexible engagement models. Hourly or fixed-scope work depending
on clarity and needs; longer-term engagements welcome when there's
ongoing product momentum.
</li>
<li>
Clean handoff. Code, documentation, and context are left in a
state where internal teams—or future vendors—can confidently
extend the work without dependency.
</li>
</ul>
</div>
</section>
<!-- Recent Engagements Section -->
<section
id="engagements"
class="section"
aria-labelledby="engagements-heading"
>
<div class="container">
<h2 id="engagements-heading" class="section-title">
Recent Engagements
</h2>
<dl class="engagements-list">
<div class="engagement">
<dt>Atlassian — Senior UI Engineer (Enterprise SaaS)</dt>
<dd>
Frontend architecture and feature delivery for Confluence
integrations, including React 18 migration work and
standardizing end-to-end testing practices.
</dd>
</div>
<div class="engagement">
<dt>CarGurus — Principal UI Engineer (Consumer Marketplace)</dt>
<dd>
Built and maintained high-traffic frontend systems, improved
Core Web Vitals and technical SEO, and developed shared UI
platforms used across teams.
</dd>
</div>
<div class="engagement">
<dt>
The TJX Companies (TJ Maxx) — UI Engineer (Enterprise Retail)
</dt>
<dd>
Delivered UX improvements for large-scale e-commerce experiences
in close partnership with design, QA, and product teams.
</dd>
</div>
<div class="engagement">
<dt>
Timberland — Senior Interactive Developer (Global Ecommerce)
</dt>
<dd>
Led global web initiatives across brand and e-commerce
platforms, acting as a technical bridge between marketing,
design, and engineering.
</dd>
</div>
<div class="engagement">
<dt>
MFA Boston — Pro Bono Technical Lead (Nonprofit / Fundraising)
</dt>
<dd>
Designed and built a custom auction application for the MFA's
annual Young Patrons fundraiser; subsequently iterated on and
supported the platform over multiple years as the event grew,
until it concluded during the pandemic.
</dd>
</div>
</dl>
</div>
</section>
<!-- Schedule Section -->
<section
id="schedule"
class="section schedule-section"
aria-labelledby="schedule-heading"
>
<div class="container">
<h2 id="schedule-heading" class="section-title">Let's Talk</h2>
<p class="schedule-text">Ready to discuss your project?</p>
<a
href="https://cal.mifi.ventures/the-mifi"
class="btn btn-primary"
target="_blank"
rel="noopener noreferrer"
aria-label="Schedule a 30-minute intro call (opens in new tab)"
>
Schedule a 30-minute intro call
</a>
</div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
<div class="container">
<p class="copyright">
© <span id="copyright-year">2026</span> mifi Ventures, LLC · Boston,
MA
</p>
<nav class="footer-links" aria-label="Social media links">
<a
href="https://linkedin.com/in/the-mifi"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile (opens in new tab)"
>LinkedIn</a
>
<a
href="https://github.com/the-mifi"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub profile (opens in new tab)"
>GitHub</a
>
</nav>
</div>
</footer>
<script src="/script.js" defer></script>
</body>
</html>

View File

@@ -1,15 +0,0 @@
/**
* Minimal JavaScript for mifi Ventures website
* Primary purpose: Dynamic copyright year
*/
(function() {
'use strict';
// Update copyright year to current year
// Script runs with defer, so DOM is always ready
const yearElement = document.getElementById('copyright-year');
if (yearElement) {
yearElement.textContent = new Date().getFullYear();
}
})();

File diff suppressed because it is too large Load Diff

696
src/app.css Normal file
View File

@@ -0,0 +1,696 @@
/* ========================================
CSS Variables for Light/Dark Mode
======================================== */
:root {
/* Light mode colors */
--color-bg: #ffffff;
--color-bg-alt: #faf9ff; /* subtle violet-tinted off-white */
--color-bg-subtle: #f3f1ff; /* soft surface */
--color-text: #14121a;
--color-text-secondary: #3f3a4a;
--color-text-tertiary: #625b70;
--color-border: #e4e0f2;
--color-border-strong: #c9c1e3;
/* Brand accent (links, focus, highlights) */
--color-primary: #6d28d9; /* purple */
--color-primary-hover: #5b21b6;
--color-primary-bg: #efe7ff;
--color-secondary: #3f3a4a;
--color-secondary-hover: #14121a;
/* Focus */
--color-focus: #6d28d9;
--color-focus-outline: rgba(109, 40, 217, 0.45);
/* Typography */
--font-family: 'Inter', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
--font-family-heading: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;
--font-size-base: 18px;
--font-size-small: 15px;
--font-size-medium: 16px;
--font-size-large: 20px;
--font-size-xl: 32px;
--font-size-xxl: 52px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-base: 1.75;
--line-height-relaxed: 1.85;
--line-height-tight: 1.65;
--line-height-heading: 1.25;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-xxl: 3rem;
--space-xxxl: 7rem;
/* Layout */
--max-width: 1100px;
--max-narrow-width: 680px;
--max-text-width: 70ch;
/* Border radius */
--border-radius: 6px;
--border-radius-small: 6px;
--border-radius-medium: 10px;
--border-radius-large: 16px;
/* Transition */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
/* CTA palette (orange primary CTA; AAA in light mode with white text) */
--accent-orange: #9a3412;
--accent-orange-hover: #7c2d12;
--accent-orange-soft: #fff1e7;
/* Button tokens */
--btn-primary-bg: var(--accent-orange);
--btn-primary-bg-hover: var(--accent-orange-hover);
--btn-primary-fg: #ffffff;
--btn-secondary-bg: transparent;
--btn-secondary-fg: var(--color-text);
--btn-secondary-border: var(--color-border-strong);
--btn-ghost-bg-hover: rgba(0, 0, 0, 0.06);
/* Focus ring (purple feels more “intentional” than orange) */
--btn-focus-ring: rgba(109, 40, 217, 0.45);
}
/* Dark mode - AAA contrast optimized */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0b0b12; /* cool slate (not pure black) */
--color-bg-alt: #121226;
--color-bg-subtle: #191934;
--color-text: #f3f2ff;
--color-text-secondary: #c9c6e4;
--color-text-tertiary: #a7a2c8;
--color-border: #2a2950;
--color-border-strong: #3a3870;
/* Brand accent (purple) */
--color-primary: #a78bfa;
--color-primary-hover: #c4b5fd;
--color-primary-bg: #1a1530;
--color-secondary: #c9c6e4;
--color-secondary-hover: #f3f2ff;
--color-focus: #a78bfa;
--color-focus-outline: rgba(167, 139, 250, 0.45);
/* CTA button: keep AAA in dark mode by using dark text on bright orange */
--btn-primary-bg: #fb923c;
--btn-primary-bg-hover: #fdba74; /* still AAA with dark text */
--btn-primary-fg: #0b0b12;
--btn-secondary-fg: var(--color-text);
--btn-secondary-border: var(--color-border-strong);
--btn-ghost-bg-hover: rgba(255, 255, 255, 0.08);
--btn-focus-ring: rgba(167, 139, 250, 0.45);
}
}
/* ========================================
Base Styles
======================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: var(--font-size-base);
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.btn:hover {
transform: none;
}
}
body {
margin: 0;
padding: 0;
font-family: var(--font-family);
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-base);
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* ========================================
Skip Link (Accessibility)
======================================== */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
border: 0;
padding: 0;
white-space: nowrap;
clip-path: inset(100%);
clip: rect(0 0 0 0);
overflow: hidden;
}
.skip-link {
position: absolute;
top: -100px;
left: 0;
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-primary);
color: white;
text-decoration: none;
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
z-index: 9999;
border-radius: 0 0 var(--border-radius-large) 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
border-bottom: none;
&:focus {
top: 0;
outline: 4px solid white;
outline-offset: 3px;
box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.3);
}
&:focus-visible {
top: 0;
outline: 4px solid white;
outline-offset: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
/* ========================================
Focus Styles (Strong, Accessible)
======================================== */
/* Strong focus indicators for keyboard navigation (WCAG 2.2 AAA) */
:focus {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
transition: outline-offset var(--transition-fast);
&:not(:focus-visible) {
outline: none;
}
}
:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
border-radius: 3px;
box-shadow: 0 0 0 8px var(--color-focus-outline);
*& {
outline-style: solid !important;
}
img& {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
}
}
/* ========================================
Typography
======================================== */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0 0 var(--space-lg) 0;
line-height: var(--line-height-heading);
font-weight: var(--font-weight-bold);
color: var(--color-text);
letter-spacing: -0.02em;
}
h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
letter-spacing: -0.03em;
}
h2 {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
letter-spacing: -0.02em;
}
/* Heading font (keeps layout intact; just typography) */
h1,
h2,
h3,
h4,
h5,
h6,
.section-title {
font-family: var(--font-family-heading);
}
p {
margin: 0 0 var(--space-md) 0;
max-width: var(--max-text-width);
}
a {
color: var(--color-primary);
text-decoration: none;
text-decoration-skip-ink: auto;
transition: color var(--transition-fast);
border-bottom: 1px solid transparent;
&:hover {
color: var(--color-primary-hover);
border-bottom-color: currentColor;
}
&:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
border-bottom-color: transparent;
}
}
/* ========================================
Layout Containers
======================================== */
.container {
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
.section {
padding: var(--space-xxxl) 0;
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
&:nth-child(even) {
background-color: var(--color-bg-alt);
}
}
/* ========================================
Buttons
======================================== */
.btn {
display: inline-block;
padding: 1rem 2rem;
min-height: 48px;
min-width: 120px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
text-decoration: none;
border-radius: var(--border-radius-large);
transition: all var(--transition-base);
cursor: pointer;
border: 2px solid transparent;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
/* PRIMARY CTA
Use the CTA/button tokens (defined in BOTH modes) to guarantee contrast.
This fixes the dark-mode purple/white contrast violation without changing your purple brand accents. */
.btn-primary {
background-color: var(--btn-primary-bg);
color: var(--btn-primary-fg);
border-color: var(--btn-primary-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
&:hover {
background-color: var(--btn-primary-bg-hover);
border-color: var(--btn-primary-bg-hover);
color: var(--btn-primary-fg);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}
&:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
box-shadow:
0 0 0 8px var(--color-focus-outline),
0 2px 8px rgba(0, 0, 0, 0.12);
}
}
/* SECONDARY CTA
Keep it outlined and “lighter touch” (more professional than flipping to a heavy block).
Uses existing tokens only; works in both modes. */
.btn-secondary {
background-color: var(--btn-secondary-bg);
color: var(--btn-secondary-fg);
border-color: var(--btn-secondary-border);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.05);
&:hover {
background-color: var(--accent-orange-soft);
color: var(--btn-secondary-fg);
border-color: var(--btn-primary-bg);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background-color: rgba(251, 146, 60, 0.12);
border-color: var(--btn-primary-bg);
color: var(--btn-secondary-fg);
}
}
&:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
box-shadow:
0 0 0 8px var(--color-focus-outline),
0 1px 4px rgba(0, 0, 0, 0.05);
}
}
/* ========================================
Section Titles
======================================== */
.section-title {
margin-bottom: var(--space-xl);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
letter-spacing: -0.02em;
line-height: var(--line-height-heading);
text-align: center;
}
/* ========================================
Content Lists
======================================== */
.content-list {
max-width: var(--max-text-width);
margin: 0 auto;
padding: 0;
list-style: none;
& li {
position: relative;
padding-left: var(--space-lg);
margin-bottom: var(--space-lg);
font-size: var(--font-size-base);
line-height: var(--line-height-relaxed);
color: var(--color-text);
&::before {
content: '→';
position: absolute;
left: 0;
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
font-size: 1.2em;
line-height: 1;
top: 0.1em;
}
}
}
/* ========================================
Nav Item and Footer Links Common Styles
======================================== */
.footer-links,
.nav-item {
display: flex;
gap: var(--space-lg);
justify-content: center;
align-items: center;
@media (max-width: 480px) {
gap: var(--space-md);
}
& a {
font-size: var(--font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
border-bottom: 1px solid transparent;
padding: var(--space-xs) var(--space-sm);
margin: calc(-1 * var(--space-xs)) calc(-1 * var(--space-sm));
min-height: 44px;
display: inline-flex;
align-items: center;
&:hover {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
&:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
border-bottom-color: transparent;
box-shadow: 0 0 0 8px var(--color-focus-outline);
border-radius: 3px;
}
@media print {
&:after {
content: none;
}
}
}
}
/* ========================================
Responsive Design
======================================== */
@media (max-width: 768px) {
:root {
--font-size-base: 17px;
--font-size-small: 14px;
--font-size-medium: 15px;
--font-size-large: 19px;
--font-size-xl: 28px;
--font-size-xxl: 40px;
--space-lg: 2rem;
--space-xl: 3rem;
--space-xxl: 4.5rem;
--space-xxxl: 6rem;
}
.section {
padding: var(--space-xxl) 0;
}
.btn {
width: 100%;
max-width: 400px;
text-align: center;
min-height: 48px;
}
}
@media (max-width: 480px) {
:root {
--font-size-base: 16px;
--font-size-small: 13px;
--font-size-medium: 14px;
--font-size-large: 18px;
--font-size-xl: 24px;
--font-size-xxl: 34px;
--space-xl: 2.5rem;
--space-xxl: 3.5rem;
--space-xxxl: 5rem;
}
.container {
padding: 0 var(--space-md);
}
.section {
padding: var(--space-xl) 0;
}
.btn {
padding: 0.875rem 1.5rem;
min-height: 48px;
}
}
/* ========================================
High Contrast Mode Support
======================================== */
@media (prefers-contrast: high) {
:root {
--color-primary: #0047b3;
--color-border: #000000;
--color-text: #000000;
--color-text-secondary: #1a1a1a;
/* Maintain button contrast in high contrast mode (same tokens, same names) */
--btn-primary-bg: #000000;
--btn-primary-bg-hover: #000000;
--btn-primary-fg: #ffffff;
--btn-secondary-border: #000000;
}
@media (prefers-color-scheme: dark) {
:root {
--color-primary: #66b3ff;
--color-border: #ffffff;
--color-text: #ffffff;
--color-text-secondary: #e0e0e0;
--btn-primary-bg: #ffffff;
--btn-primary-bg-hover: #ffffff;
--btn-primary-fg: #000000;
--btn-secondary-border: #ffffff;
}
}
.btn {
border-width: 3px;
&:hover {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
}
:focus,
:focus-visible {
outline-width: 4px;
outline-offset: 4px;
}
a {
border-bottom-width: 2px;
text-decoration: underline;
}
}
/* ========================================
Print Styles (Accessibility)
======================================== */
@media print {
/* Show all content clearly for printing */
body {
font-size: 12pt;
line-height: 1.5;
color: #000;
background: #fff;
}
.skip-link {
display: none;
}
/* Expand all sections */
.section {
page-break-inside: avoid;
padding: 1rem 0;
border-bottom: 1pt solid #ccc;
}
/* Show URLs for external links */
a[href^='http']:after {
content: ' (' attr(href) ')';
font-size: 10pt;
color: #666;
}
/* Hide interactive elements that don't make sense in print */
.btn,
.cta-group {
display: none;
}
/* Ensure good contrast */
* {
color: #000 !important;
background: #fff !important;
}
h1,
h2,
h3,
dt {
color: #000 !important;
font-weight: bold;
}
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { engagements } from '$lib/data/engagements';
</script>
<section id="engagements" class="section" aria-labelledby="engagements-heading">
<div class="container">
<h2 id="engagements-heading" class="section-title">Recent Engagements</h2>
<dl class="engagements-list">
{#each engagements as engagement (engagement.title)}
<div class="engagement">
<dt>{engagement.title}</dt>
<dd>{engagement.description}</dd>
</div>
{/each}
</dl>
</div>
</section>
<style>
.engagements-list {
max-width: var(--max-text-width);
margin: 0 auto;
}
.engagement {
margin-bottom: var(--space-xl);
padding-bottom: var(--space-lg);
padding-left: var(--space-md);
border-left: 3px solid var(--color-border);
transition: border-color var(--transition-base);
&:hover,
&:focus-within {
border-left-color: var(--color-primary);
}
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
@media (max-width: 768px) {
padding-left: var(--space-sm);
}
@media (prefers-contrast: high) {
border-left-width: 4px;
}
& dt {
margin-bottom: var(--space-sm);
font-size: var(--font-size-large);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
line-height: var(--line-height-tight);
}
& dd {
margin: 0;
font-size: var(--font-size-base);
line-height: var(--line-height-relaxed);
color: var(--color-text-secondary);
}
}
</style>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import { experienceLogos, experienceTextList } from '$lib/data/experience';
</script>
<section
id="experience"
class="section experience-section"
aria-labelledby="experience-heading"
>
<div class="container">
<h2 id="experience-heading" class="section-title">Previously at:</h2>
<div class="logo-strip" role="list" aria-label="Company logos">
{#each experienceLogos.filter((logo) => logo.showLogo) as logo (logo.alt)}
<div class="logo-item" role="listitem">
<img
src={logo.src}
alt={logo.alt}
loading="lazy"
width={logo.width}
height={logo.height}
/>
<span class="logo-fallback-text">{logo.alt}</span>
</div>
{/each}
</div>
<ul class="logo-text-list" aria-hidden="true">
{#each experienceTextList as name (name)}
<li>{name}</li>
{/each}
</ul>
<p class="footnote">Logos are trademarks of their respective owners.</p>
</div>
</section>
<style>
.experience-section {
text-align: center;
background-color: var(--color-bg);
}
.logo-strip {
display: flex;
flex-wrap: wrap;
gap: var(--space-xl);
justify-content: center;
align-items: center;
margin: 0;
padding: var(--space-lg) 0;
@media (max-width: 480px) {
display: none;
}
@media (max-width: 768px) and (min-width: 481px) {
gap: var(--space-lg);
padding: var(--space-md) 0;
}
@media (max-width: 768px) {
gap: var(--space-md);
}
@media print {
display: none;
}
}
.logo-item {
position: relative;
flex: 0 1 auto;
min-width: 120px;
max-width: 160px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
/* Make logo containers keyboard focusable for screen reader users */
&:focus-within {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
border-radius: var(--border-radius);
}
@media (max-width: 768px) and (min-width: 481px) {
min-width: 110px;
max-width: 140px;
}
@media (max-width: 768px) {
max-width: 120px;
}
@media (max-width: 480px) {
max-width: 100px;
}
& img {
width: 100%;
height: auto;
max-height: 50px;
object-fit: contain;
opacity: 0.75;
transition: all var(--transition-base);
filter: grayscale(100%) contrast(1.15);
&:hover,
&:focus,
&:focus-visible {
opacity: 1;
filter: grayscale(25%) contrast(1.05);
transform: scale(1.05);
}
&:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
border-radius: var(--border-radius);
}
&[alt]:not([src]),
&[alt][src=''],
&[alt]:not([src*='.svg']):not([src*='.png']):not([src*='.jpg']) {
display: none;
}
@media (max-width: 768px) and (min-width: 481px) {
max-height: 45px;
}
@media (prefers-reduced-motion: reduce) {
&:hover,
&:focus {
transform: none;
}
}
/* Dark mode logo adaptations */
@media (prefers-color-scheme: dark) {
filter: grayscale(100%) brightness(0) invert(1) contrast(1.25);
opacity: 0.65;
&:hover,
&:focus,
&:focus-visible {
filter: grayscale(50%) brightness(1) invert(1) contrast(1.1);
opacity: 0.9;
}
}
@media (prefers-contrast: high) {
opacity: 1;
filter: contrast(1.6);
@media (prefers-color-scheme: dark) {
filter: brightness(0) invert(1) contrast(1.9);
opacity: 1;
}
}
@media print {
opacity: 1;
filter: none;
max-height: 40px;
}
}
}
/* Fallback text (shown when image fails to load or on very small screens) */
.logo-fallback-text {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.logo-item:has(img[alt]:not([src])) .logo-fallback-text,
.logo-item:has(img[alt][src='']) .logo-fallback-text {
position: static;
width: auto;
height: auto;
padding: var(--space-sm);
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-alt);
border: 2px solid var(--color-border);
border-radius: var(--border-radius);
display: inline-block;
}
/* Text-only list (hidden by default, shown on very small screens) */
.logo-text-list {
display: none;
list-style: none;
padding: 0;
margin: 0 auto;
max-width: 400px;
text-align: left;
& li {
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-sm);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text);
background-color: var(--color-bg-subtle);
border-left: 3px solid var(--color-primary);
border-radius: var(--border-radius);
line-height: var(--line-height-base);
@media (prefers-contrast: high) {
border-left-width: 4px;
}
}
&:last-child {
margin-bottom: 0;
}
@media (max-width: 480px) {
display: block;
}
@media print {
display: block !important;
}
}
.footnote {
margin-top: var(--space-lg);
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
color: var(--color-text-tertiary);
font-style: italic;
line-height: var(--line-height-base);
max-width: 100%;
@media (max-width: 480px) {
display: none;
}
}
@media (prefers-contrast: high) {
.logo-item img {
opacity: 1;
filter: contrast(1.6);
}
@media (prefers-color-scheme: dark) {
.logo-item img {
filter: brightness(0) invert(1) contrast(1.9);
opacity: 1;
}
}
.logo-text-list li {
border-left-width: 4px;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import LinkedInIcon from './Icon/LinkedIn.svelte';
import GithubIcon from './Icon/Github.svelte';
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
</script>
<footer class="footer">
<div class="container">
<p class="copyright">
© <span id="copyright-year">2026</span> mifi Ventures, LLC · Boston, MA
</p>
<nav class="footer-links" aria-label="Social media links">
<a
class="link"
href="https://linkedin.com/in/the-mifi"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile (opens in new tab)"
>
<LinkedInIcon size={15} />
LinkedIn
<ExternalLinkIcon aria-label="Opens in new tab" size={15} />
</a>
<a
class="link"
href="https://github.com/the-mifi"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub profile (opens in new tab)"
>
<GithubIcon size={15} />
GitHub
<ExternalLinkIcon aria-label="Opens in new tab" size={15} />
</a>
</nav>
</div>
</footer>
<style>
.footer {
padding: var(--space-xxl) 0 var(--space-xl) 0;
text-align: center;
background-color: var(--color-bg);
border-top: 1px solid var(--color-border);
}
.copyright {
margin-bottom: var(--space-md);
font-size: var(--font-size-medium);
font-weight: var(--font-weight-normal);
color: var(--color-text-tertiary);
max-width: 100%;
}
.link {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
import FiletypePdfIcon from './Icon/FiletypePdf.svelte';
import Logo from './Logo.svelte';
</script>
<header id="header" class="hero">
<div class="container">
<Logo />
<p class="headline">Software Engineering Consulting</p>
<p class="subhead">
Principal: Mike Fitzpatrick — senior full-stack engineer and architect helping
teams ship reliable, accessible, high-performance web products.
</p>
<div class="cta-group">
<a
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=hero_cta"
class="btn btn-primary icon-button"
target="_blank"
rel="noopener noreferrer"
aria-label="Schedule a 30-minute intro call (opens in new tab)"
>
Schedule a 30-minute intro call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
<a
href="/downloads/resume.pdf"
class="btn btn-secondary icon-button"
download
aria-label="Download Mike Fitzpatrick's resume as PDF"
>
Download resume
<FiletypePdfIcon aria-label="PDF format file" size={17} />
</a>
</div>
</div>
</header>
<style>
.hero {
padding: var(--space-xxxl) 0 var(--space-xxl) 0;
text-align: center;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
@media (max-width: 768px) {
padding: var(--space-xxl) 0 var(--space-xl) 0;
}
@media (max-width: 480px) {
padding: var(--space-xl) 0 var(--space-lg) 0;
}
}
.headline {
margin-bottom: var(--space-lg);
font-family: var(--font-family-heading);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
letter-spacing: -0.02em;
}
.subhead {
max-width: var(--max-narrow-width);
margin: 0 auto var(--space-xl) auto;
font-size: var(--font-size-large);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.cta-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
justify-content: center;
align-items: center;
margin-top: var(--space-lg);
@media (max-width: 768px) {
flex-direction: column;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { howWeWorkItems } from '$lib/data/content';
</script>
<section id="how-we-work" class="section" aria-labelledby="how-we-work-heading">
<div class="container">
<h2 id="how-we-work-heading" class="section-title">How We Work</h2>
<ul class="content-list">
{#each howWeWorkItems as item (item)}
<li>{item}</li>
{/each}
</ul>
</div>
</section>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
const {
'aria-label': ariaLabel,
class: className,
color = 'currentColor',
size,
} = $props<{
'aria-label'?: string;
class?: string;
color?: string;
size?: number;
}>();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={color}
class={className}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role={ariaLabel ? undefined : 'presentation'}
width={size}
height={size}
>
<path
d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z"
>
</path>
</svg>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
const {
'aria-label': ariaLabel,
class: className,
color = 'currentColor',
size,
} = $props<{
'aria-label'?: string;
class?: string;
color?: string;
size?: number;
}>();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={color}
class={className}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role={ariaLabel ? undefined : 'presentation'}
width={size}
height={size}
>
<path
d="M5 4H15V8H19V20H5V4ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM10.4999 7.5C10.4999 9.07749 10.0442 10.9373 9.27493 12.6534C8.50287 14.3757 7.46143 15.8502 6.37524 16.7191L7.55464 18.3321C10.4821 16.3804 13.7233 15.0421 16.8585 15.49L17.3162 13.5513C14.6435 12.6604 12.4999 9.98994 12.4999 7.5H10.4999ZM11.0999 13.4716C11.3673 12.8752 11.6042 12.2563 11.8037 11.6285C12.2753 12.3531 12.8553 13.0182 13.5101 13.5953C12.5283 13.7711 11.5665 14.0596 10.6352 14.4276C10.7999 14.1143 10.9551 13.7948 11.0999 13.4716Z"
>
</path>
</svg>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
const {
'aria-label': ariaLabel,
class: className,
color = 'currentColor',
size,
} = $props<{
'aria-label'?: string;
class?: string;
color?: string;
size?: number;
}>();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={color}
class={className}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role={ariaLabel ? undefined : 'presentation'}
width={size}
height={size}
>
<path
d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"
>
</path>
</svg>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
const {
'aria-label': ariaLabel,
class: className,
color = 'currentColor',
size,
} = $props<{
'aria-label'?: string;
class?: string;
color?: string;
size?: number;
}>();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={color}
class={className}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role={ariaLabel ? undefined : 'presentation'}
width={size}
height={size}
>
<path
d="M18.3362 18.339H15.6707V14.1622C15.6707 13.1662 15.6505 11.8845 14.2817 11.8845C12.892 11.8845 12.6797 12.9683 12.6797 14.0887V18.339H10.0142V9.75H12.5747V10.9207H12.6092C12.967 10.2457 13.837 9.53325 15.1367 9.53325C17.8375 9.53325 18.337 11.3108 18.337 13.6245V18.339H18.3362ZM7.00373 8.57475C6.14573 8.57475 5.45648 7.88025 5.45648 7.026C5.45648 6.1725 6.14648 5.47875 7.00373 5.47875C7.85873 5.47875 8.55173 6.1725 8.55173 7.026C8.55173 7.88025 7.85798 8.57475 7.00373 8.57475ZM8.34023 18.339H5.66723V9.75H8.34023V18.339ZM19.6697 3H4.32923C3.59498 3 3.00098 3.5805 3.00098 4.29675V19.7033C3.00098 20.4202 3.59498 21 4.32923 21H19.6675C20.401 21 21.001 20.4202 21.001 19.7033V4.29675C21.001 3.5805 20.401 3 19.6675 3H19.6697Z"
>
</path>
</svg>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { impactItems } from '$lib/data/content';
</script>
<section id="impact" class="section" aria-labelledby="impact-heading">
<div class="container">
<h2 id="impact-heading" class="section-title">Selected Impact</h2>
<ul class="content-list">
{#each impactItems as item (item)}
<li>{item}</li>
{/each}
</ul>
</div>
</section>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import Wordmark from './Wordmark.svelte';
</script>
<h1 class="logo">
<Wordmark />
<span class="sr-only">mifi Ventures</span>
</h1>
<style>
.logo {
color: var(--color-text);
font-family: var(--font-family-heading);
margin: 0 auto;
max-width: 350px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import Wordmark from './Wordmark.svelte';
</script>
<nav id="nav" class="nav" aria-label="Main navigation">
<input
type="checkbox"
id="nav-toggle"
class="nav-toggle-input"
aria-hidden="true"
hidden
/>
<div class="mobile-nav-header">
<span class="mobile nav-header-logo">
<Wordmark />
</span>
<button
type="button"
id="nav-toggle-button"
class="btn-clear"
aria-controls="nav-menu"
aria-label="Toggle navigation"
aria-expanded="false"
>
<label
id="nav-toggle-label"
for="nav-toggle"
class="nav-toggle"
role="presentation"
>
<span class="nav-toggle-inner">
<span class="nav-toggle-line"></span>
<span class="nav-toggle-line"></span>
<span class="nav-toggle-line"></span>
</span>
</label>
</button>
</div>
<div id="nav-menu" class="nav-menu container">
<span class="nav-header-logo desktop">
<Wordmark />
</span>
<ul class="nav-list">
<li class="nav-item">
<a href="#what-we-do" class="nav-link">Services</a>
</li>
<li class="nav-item">
<a href="#impact" class="nav-link">Impact</a>
</li>
<li class="nav-item">
<a href="#how-we-work" class="nav-link">Process</a>
</li>
<li class="nav-item">
<a href="#schedule" class="nav-link">Contact</a>
</li>
</ul>
<div class="nav-item nav-back-to-top">
<a href="#header" class="nav-link">Back to top</a>
</div>
</div>
</nav>
<style>
.nav {
background-color: var(--color-bg);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding-top: var(--space-sm);
position: sticky;
text-align: center;
top: 0;
z-index: 100;
@media (max-width: 768px) {
align-items: stretch;
display: flex;
flex-direction: column;
padding: var(--space-md) 0;
}
}
.container,
.nav-menu {
display: grid;
grid-template-columns: 1fr auto 1fr;
justify-content: space-between;
align-items: center;
}
.mobile-nav-header {
anchor-name: --mobile-nav-header;
display: none;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
display: flex;
}
}
.nav-header-logo {
color: var(--color-text);
display: inline-block;
max-width: 250px;
padding-left: var(--space-md);
&.mobile {
display: none;
}
@media (max-width: 768px) {
&.desktop {
display: none;
}
&.mobile {
display: inline-block;
}
}
}
/* Hamburger toggle: mobile only, animates to X when open */
.nav-toggle-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
&:checked {
& ~ .mobile-nav-header .nav-toggle .nav-toggle-line {
&:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
&:nth-child(2) {
opacity: 0;
}
&:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
}
& ~ .nav-menu {
max-height: 80vh;
opacity: 1;
padding-top: var(--space-md);
padding-bottom: var(--space-md);
border-top: 1px solid var(--color-border);
}
}
}
.btn-clear {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
}
.nav-toggle {
display: none;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
padding: var(--space-sm) var(--space-md);
width: calc(24px + var(--space-md) + var(--space-md));
height: calc(31px + var(--space-sm) + var(--space-sm));
cursor: pointer;
color: var(--color-text);
background: transparent;
border: none;
border-radius: var(--space-xs);
transition:
color 0.2s ease,
background-color 0.2s ease;
@media (max-width: 768px) {
display: flex;
}
&:hover {
color: var(--color-primary);
}
&:focus-visible {
outline: 4px solid var(--color-focus);
outline-offset: 4px;
}
}
.nav-toggle-inner {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
}
.nav-toggle-line {
display: block;
width: 100%;
height: 2px;
background-color: currentColor;
border-radius: 1px;
transform-origin: center;
transition:
transform 0.25s ease,
opacity 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition-duration: 0.01ms;
}
}
.nav-list {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
}
.nav-item {
margin: 0 var(--space-md);
}
/* Back to top + mobile nav logo: hidden until page is scrolled (CSS scroll-driven animation) */
.nav-back-to-top,
.nav-header-logo {
/* Fallback when scroll-driven animations arent supported: always visible */
opacity: 1;
visibility: visible;
}
/* Mobile: show toggle, collapse menu until opened; menu overlays content via anchor */
@media (max-width: 768px) {
.nav-menu {
display: flex;
flex-direction: column;
align-items: stretch;
max-height: 0;
overflow: hidden;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
border-top: none;
transition:
max-height 0.3s ease,
opacity 0.25s ease,
padding 0.25s ease;
& .nav-list,
& .nav-item {
width: 100%;
}
& .nav-list {
flex-direction: column;
gap: 0;
}
& .nav-item {
margin: 0;
text-align: center;
& a:hover {
border-bottom-color: transparent;
}
}
& .nav-item a,
& .nav-back-to-top a {
display: block;
padding: var(--space-md);
}
@supports (top: anchor(bottom)) {
position: fixed;
position-anchor: --mobile-nav-header;
top: anchor(--mobile-nav-header bottom);
left: anchor(--mobile-nav-header left);
right: anchor(--mobile-nav-header right);
margin: 0;
overflow-y: auto;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 99;
}
@media (prefers-reduced-motion: reduce) {
transition-duration: 0.01ms;
}
}
}
@supports (animation-timeline: scroll()) {
/* Shadow on pseudo-element; only opacity is animated (composited) */
.nav::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
pointer-events: none;
opacity: 0;
animation: nav-shadow-on-scroll linear;
animation-timeline: scroll(root block);
animation-range: 0 100px;
animation-fill-mode: both;
will-change: opacity;
}
@keyframes nav-shadow-on-scroll {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.nav::after {
animation: none;
opacity: 1;
}
}
/* Composited-only animation: opacity only (visibility/pointer-events not animated) */
.nav-back-to-top,
.nav-header-logo {
opacity: 0;
animation: nav-reveal-on-scroll linear;
animation-timeline: scroll(root block);
animation-range: 300px 400px;
animation-fill-mode: both;
will-change: opacity;
}
@keyframes nav-reveal-on-scroll {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.nav-back-to-top,
.nav-header-logo {
animation: none;
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
</script>
<section
id="schedule"
class="section schedule-section"
aria-labelledby="schedule-heading"
>
<div class="container">
<h2 id="schedule-heading" class="section-title">Let's Talk</h2>
<p class="schedule-text">Ready to discuss your project?</p>
<a
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=schedule_section_cta"
class="btn btn-primary icon-button"
target="_blank"
rel="noopener noreferrer"
aria-label="Schedule a 30-minute intro call (opens in new tab)"
>
Schedule a 30-minute intro call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
</div>
</section>
<style>
.schedule-section {
text-align: center;
background-color: var(--color-bg-subtle);
}
.schedule-text {
margin-bottom: var(--space-lg);
font-size: var(--font-size-large);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
max-width: 100%;
}
.btn {
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { whatWeDoItems } from '$lib/data/content';
</script>
<section id="what-we-do" class="section" aria-labelledby="what-we-do-heading">
<div class="container">
<h2 id="what-we-do-heading" class="section-title">What We Do</h2>
<ul class="content-list">
{#each whatWeDoItems as item (item)}
<li>{item}</li>
{/each}
</ul>
</div>
</section>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
const { color = 'currentColor' } = $props<{ color?: string }>();
</script>
<svg
width="100%"
height="100%"
viewBox="0 0 3934 513"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
><g
><path
fill={color}
d="M0,504.667l0,-362.333l82.5,0l0,83.5l-9.5,-13.5c6.444,-26.222 19.75,-45.778 39.917,-58.667c20.167,-12.889 43.806,-19.333 70.917,-19.333c29.556,0 55.694,7.694 78.417,23.083c22.722,15.389 37.417,35.861 44.083,61.417l-25,2.167c11.222,-29.222 27.917,-50.972 50.083,-65.25c22.167,-14.278 47.75,-21.417 76.75,-21.417c25.667,0 48.611,5.778 68.833,17.333c20.222,11.556 36.194,27.611 47.917,48.167c11.722,20.556 17.583,44.333 17.583,71.333l0,233.5l-87.5,0l0,-212.667c0,-16.111 -2.889,-29.889 -8.667,-41.333c-5.778,-11.444 -13.833,-20.361 -24.167,-26.75c-10.333,-6.389 -22.667,-9.583 -37,-9.583c-13.889,0 -26.139,3.194 -36.75,9.583c-10.611,6.389 -18.833,15.333 -24.667,26.833c-5.833,11.5 -8.75,25.25 -8.75,41.25l0,212.667l-87.5,0l0,-212.667c0,-16.111 -2.889,-29.889 -8.667,-41.333c-5.778,-11.444 -13.833,-20.361 -24.167,-26.75c-10.333,-6.389 -22.667,-9.583 -37,-9.583c-14,0 -26.278,3.194 -36.833,9.583c-10.556,6.389 -18.75,15.333 -24.583,26.833c-5.833,11.5 -8.75,25.25 -8.75,41.25l0,212.667l-87.5,0Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M614.5,504.667l0,-362.333l87.5,0l0,362.333l-87.5,0Zm0,-403.333l0,-93.333l87.5,0l0,93.333l-87.5,0Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M823.5,504.667l0,-284.833l-63.833,0l0,-77.5l63.833,0l0,-12c0,-27.889 5.639,-51.472 16.917,-70.75c11.278,-19.278 27.194,-34.028 47.75,-44.25c20.556,-10.222 44.778,-15.333 72.667,-15.333c5.444,0 11.389,0.333 17.833,1c6.444,0.667 11.778,1.444 16,2.333l0,75.333c-4.111,-0.889 -8.083,-1.444 -11.917,-1.667c-3.833,-0.222 -7.361,-0.333 -10.583,-0.333c-19.333,0 -34.361,4.361 -45.083,13.083c-10.722,8.722 -16.083,22.25 -16.083,40.583l0,12l158.667,0l0,77.5l-158.667,0l0,284.833l-87.5,0Zm213.667,0l0,-362.333l87.5,0l0,362.333l-87.5,0Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M1474.74,50.297c0,-3.892 1.365,-6.915 4.096,-9.068c2.731,-2.153 6.86,-3.229 12.388,-3.229l106.333,0c5.83,0 10.083,1.052 12.76,3.156c2.677,2.104 4.016,5.056 4.016,8.854c0,2.844 -0.913,5.219 -2.74,7.125c-1.826,1.906 -5.043,3.74 -9.651,5.5l-13.286,4.479c-6.694,2.681 -12.347,6.961 -16.958,12.841c-4.611,5.88 -8.964,15.216 -13.057,28.008l-114.453,337.807c-2.128,6.608 -3.694,12.082 -4.695,16.424c-1.002,4.342 -1.503,8.846 -1.503,13.513l0,15.141c0,4.351 -1.242,7.741 -3.727,10.172c-2.484,2.431 -5.817,3.646 -9.997,3.646l-71.854,0c-4.354,0 -7.762,-1.215 -10.224,-3.646c-2.462,-2.431 -3.693,-5.965 -3.693,-10.604l0,-14.969c0,-3.542 -0.508,-7.257 -1.523,-11.146c-1.016,-3.889 -2.428,-8.453 -4.237,-13.693l-125.854,-363.062c-2.097,-6.191 -4.4,-10.66 -6.909,-13.406c-2.509,-2.747 -5.987,-4.977 -10.435,-6.693l-14.141,-4.193c-7.497,-2.809 -11.245,-7.128 -11.245,-12.958c0,-3.892 1.394,-6.915 4.182,-9.068c2.788,-2.153 7.002,-3.229 12.641,-3.229l151.687,0c5.75,0 9.968,1.076 12.654,3.229c2.686,2.153 4.029,5.175 4.029,9.068c0,3.128 -1.044,5.646 -3.133,7.552c-2.089,1.906 -5.221,3.55 -9.398,4.932l-25.328,4.427c-5.542,1.573 -8.939,4.237 -10.193,7.992c-1.253,3.755 -0.444,9.841 2.427,18.258l124.146,361.656l-26.328,20.599l125.198,-369.797c3.542,-10.618 4.107,-18.966 1.695,-25.044c-2.411,-6.078 -9.02,-10.76 -19.826,-14.044l-21.573,-4.193c-3.972,-1.382 -7.014,-2.978 -9.125,-4.789c-2.111,-1.811 -3.167,-4.327 -3.167,-7.549Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M1905.721,311.365c0,9.878 -2.872,17.47 -8.615,22.776c-5.743,5.306 -14.118,7.958 -25.125,7.958l-210,0l0,-21.203l147.052,0c10.229,0 15.344,-4.622 15.344,-13.865c0,-30.597 -5.845,-54.022 -17.536,-70.273c-11.691,-16.252 -27.196,-24.378 -46.516,-24.378c-15.257,0 -28.655,4.508 -40.195,13.523c-11.54,9.016 -20.561,21.96 -27.062,38.833c-6.502,16.873 -9.753,37.037 -9.753,60.492c0,43.872 10.266,76.974 30.799,99.307c20.533,22.333 47.546,33.5 81.039,33.5c21.031,0 39.325,-4.677 54.88,-14.031c15.556,-9.354 26.748,-22.125 33.578,-38.313c2.969,-3.653 5.411,-6.146 7.326,-7.479c1.915,-1.333 3.937,-2 6.065,-2c2.938,0 5.082,1.291 6.432,3.872c1.351,2.582 1.97,5.721 1.859,9.419c-1.174,18.809 -7.769,36.038 -19.786,51.688c-12.017,15.649 -28.122,28.109 -48.315,37.38c-20.193,9.271 -43.218,13.906 -69.076,13.906c-31.069,0 -58.526,-6.501 -82.37,-19.503c-23.844,-13.002 -42.481,-31.285 -55.911,-54.849c-13.431,-23.564 -20.146,-51.166 -20.146,-82.805c0,-32.92 6.497,-62.108 19.49,-87.562c12.993,-25.455 31.546,-45.48 55.659,-60.076c24.113,-14.595 52.768,-21.893 85.966,-21.893c27.938,-0 51.99,5.361 72.159,16.083c20.168,10.722 35.67,25.512 46.505,44.37c10.835,18.858 16.253,40.564 16.253,65.12Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M2076.711,205.234l0,253.599c0,6.017 0.997,10.479 2.99,13.385c1.993,2.906 4.998,4.97 9.016,6.193l14.234,3.453c6.413,2.337 9.62,6.03 9.62,11.078c0,7.816 -5.082,11.724 -15.245,11.724l-124.286,0c-5.035,0 -8.755,-1.004 -11.161,-3.013c-2.406,-2.009 -3.609,-4.721 -3.609,-8.138c0,-2.733 0.865,-5.056 2.596,-6.969c1.731,-1.913 4.438,-3.458 8.122,-4.635l15.234,-3.5c4.031,-1.222 7.04,-3.262 9.026,-6.12c1.986,-2.858 2.979,-7.281 2.979,-13.271l0,-204.677c0,-4.844 -0.782,-8.342 -2.346,-10.495c-1.564,-2.153 -4.126,-3.467 -7.685,-3.943l-20.542,-1c-3.559,-0.667 -6.109,-1.819 -7.651,-3.456c-1.542,-1.637 -2.312,-3.734 -2.312,-6.289c0,-2.972 0.918,-5.387 2.753,-7.245c1.835,-1.858 5.192,-3.66 10.07,-5.406l62.24,-21.5c6.941,-2.556 12.56,-4.39 16.857,-5.503c4.297,-1.113 8.263,-1.669 11.898,-1.669c5.688,0 9.977,1.576 12.867,4.729c2.891,3.153 4.336,7.375 4.336,12.667Zm-9.385,71.365l-12.724,-13.036l13.526,-11.927c26.417,-23.639 49.119,-40.515 68.107,-50.628c18.988,-10.113 37.15,-15.169 54.487,-15.169c26.26,0 46.657,8.724 61.19,26.172c14.533,17.448 23.444,41.141 26.732,71.078l20.208,174.552c0.729,6.417 2.038,11.217 3.927,14.401c1.889,3.184 4.993,5.387 9.313,6.609l13.427,3.26c3.684,1.16 6.391,2.697 8.122,4.612c1.731,1.915 2.596,4.246 2.596,6.992c0,3.417 -1.175,6.129 -3.526,8.138c-2.351,2.009 -6.115,3.013 -11.292,3.013l-125.594,0c-10.198,0 -15.297,-3.908 -15.297,-11.724c-0,-5.017 3.177,-8.71 9.531,-11.078l14.896,-3.453c4.448,-1.222 7.823,-3.425 10.125,-6.609c2.302,-3.184 3.087,-7.905 2.354,-14.161l-18.995,-162.974c-2.476,-20.635 -7.75,-36.081 -15.823,-46.336c-8.073,-10.255 -19.927,-15.383 -35.562,-15.383c-9.844,0 -20.199,2.655 -31.065,7.966c-10.866,5.311 -22.546,13.293 -35.039,23.945l-13.625,11.74Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M2387.845,220.714l-18.271,-4.667c-4.927,-1.479 -8.352,-3.2 -10.273,-5.161c-1.922,-1.962 -2.883,-4.276 -2.883,-6.943c0,-3.51 1.223,-6.199 3.669,-8.065c2.446,-1.866 5.704,-2.799 9.773,-2.799l21.99,0c5.101,-0 9.294,-0.87 12.581,-2.609c3.286,-1.74 6.416,-4.936 9.388,-9.589l34.552,-51.276c3.59,-4.91 7.104,-8.504 10.542,-10.784c3.438,-2.28 6.943,-3.419 10.516,-3.419c3.878,0 6.89,1.22 9.034,3.659c2.144,2.439 3.216,5.905 3.216,10.398l0,288.479c0,15.747 3.147,27.707 9.44,35.88c6.293,8.174 15.034,12.26 26.221,12.26c7.67,0 13.736,-1.373 18.198,-4.12c4.462,-2.747 8.049,-6.002 10.763,-9.766c2.714,-3.764 5.304,-7.236 7.771,-10.417c2.467,-3.181 5.447,-5.205 8.94,-6.073c2.733,-0.177 4.901,0.624 6.505,2.404c1.604,1.78 2.375,4.773 2.312,8.982c-0.507,11.427 -4.431,21.975 -11.773,31.643c-7.342,9.668 -17.384,17.448 -30.125,23.339c-12.741,5.891 -27.367,8.836 -43.878,8.836c-26.191,0 -46.827,-6.628 -61.909,-19.883c-15.082,-13.255 -22.622,-33.345 -22.622,-60.268l0,-192.13c0,-5.128 -1.021,-9.007 -3.063,-11.635c-2.042,-2.628 -5.58,-4.72 -10.615,-6.276Zm61.109,-0.854l0.281,-26.781l106.25,0c4.444,-0 7.866,0.858 10.266,2.573c2.399,1.715 3.599,4.257 3.599,7.625c0,4.733 -2.383,8.68 -7.148,11.841c-4.766,3.161 -12.326,4.742 -22.68,4.742l-90.568,0Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M2855.8,465.307l0,-21.885l-2.146,-1.526l0,-187.313c0,-4.847 -0.782,-8.346 -2.346,-10.497c-1.564,-2.151 -4.126,-3.464 -7.685,-3.94l-20.542,-1.005c-3.559,-0.667 -6.109,-1.818 -7.648,-3.453c-1.54,-1.635 -2.31,-3.733 -2.31,-6.292c0,-2.969 0.917,-5.382 2.75,-7.24c1.833,-1.858 5.189,-3.661 10.068,-5.411l62.24,-21.495c6.924,-2.559 12.538,-4.394 16.844,-5.505c4.306,-1.111 8.276,-1.667 11.911,-1.667c5.687,0 9.977,1.576 12.867,4.729c2.891,3.153 4.336,7.373 4.336,12.661l0,253.365c0,6.017 0.997,10.503 2.99,13.456c1.993,2.953 4.998,4.994 9.016,6.122l14.516,3.312c3.812,1.16 6.609,2.701 8.388,4.622c1.78,1.922 2.669,4.312 2.669,7.169c0,3.417 -1.223,6.129 -3.669,8.138c-2.446,2.009 -6.258,3.013 -11.435,3.013l-65.932,0c-10.229,0 -18.6,-3.578 -25.112,-10.734c-6.512,-7.156 -9.768,-16.698 -9.768,-28.625Zm-208.76,-50.839l0,-159.885c0,-4.847 -0.786,-8.346 -2.357,-10.497c-1.571,-2.151 -4.143,-3.464 -7.716,-3.94l-20.547,-1.005c-3.559,-0.667 -6.109,-1.818 -7.648,-3.453c-1.54,-1.635 -2.31,-3.733 -2.31,-6.292c0,-2.969 0.918,-5.382 2.753,-7.24c1.835,-1.858 5.19,-3.661 10.065,-5.411l62.286,-21.495c7.226,-2.653 12.964,-4.511 17.214,-5.576c4.25,-1.064 7.908,-1.596 10.974,-1.596c5.972,0 10.428,1.576 13.367,4.729c2.939,3.153 4.409,7.373 4.409,12.661l0,197.422c0,20.92 5.023,36.531 15.07,46.833c10.047,10.302 23.452,15.453 40.216,15.453c10.382,0 21.431,-2.572 33.146,-7.716c11.715,-5.144 23.986,-13.207 36.813,-24.19l13.62,-11.74l12.724,13.031l-13.526,11.927c-26.653,24.323 -49.98,41.37 -69.982,51.141c-20.002,9.771 -38.973,14.656 -56.914,14.656c-27.354,0 -49.469,-8.744 -66.344,-26.232c-16.875,-17.488 -25.313,-41.35 -25.313,-71.586Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M3130.82,330.943c0,-31.67 4.576,-58.287 13.729,-79.852c9.153,-21.564 21.071,-37.831 35.755,-48.799c14.684,-10.969 30.319,-16.453 46.906,-16.453c20.118,0 35.681,5.683 46.69,17.049c11.009,11.366 16.513,27.369 16.513,48.008c0,17.24 -3.655,30.185 -10.964,38.836c-7.309,8.651 -16.743,12.977 -28.302,12.977c-11.556,0 -20.418,-3.171 -26.586,-9.513c-6.168,-6.342 -9.284,-15.235 -9.346,-26.68l-0.047,-11.573c-0.111,-7.236 -1.802,-12.64 -5.073,-16.211c-3.271,-3.571 -8.644,-5.357 -16.12,-5.357c-8.635,0 -16.97,3.531 -25.003,10.594c-8.033,7.062 -14.597,17.733 -19.693,32.01c-5.095,14.278 -7.643,32.392 -7.643,54.344l-10.818,0.62Zm6.812,-125.427l4.005,81.208l0,171.87c0,5.497 1.199,9.661 3.596,12.495c2.398,2.833 6.598,4.719 12.602,5.656l29.714,4.427c4.462,0.701 7.769,2.029 9.922,3.982c2.153,1.953 3.229,4.694 3.229,8.221c0,3.51 -1.299,6.27 -3.896,8.279c-2.597,2.009 -6.398,3.013 -11.401,3.013l-147.318,0c-5.083,0 -8.836,-1.013 -11.258,-3.039c-2.422,-2.026 -3.633,-4.739 -3.633,-8.138c0,-2.747 0.885,-5.085 2.656,-7.016c1.771,-1.931 4.514,-3.483 8.229,-4.656l15.068,-3.406c4.035,-1.128 7.044,-3.137 9.029,-6.026c1.984,-2.889 2.977,-7.295 2.977,-13.219l0,-204.391c0,-4.861 -0.779,-8.379 -2.336,-10.555c-1.557,-2.175 -4.114,-3.503 -7.669,-3.982l-20.667,-1c-3.51,-0.667 -6.04,-1.818 -7.589,-3.453c-1.549,-1.635 -2.323,-3.724 -2.323,-6.266c0,-2.955 0.942,-5.393 2.826,-7.315c1.884,-1.922 5.232,-3.709 10.044,-5.362l61.26,-20.76c8.799,-3.306 15.307,-5.466 19.526,-6.482c4.219,-1.016 7.575,-1.523 10.068,-1.523c4.08,0 7.156,1.345 9.229,4.036c2.073,2.691 3.443,7.158 4.109,13.401Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M3622.771,311.365c0,9.878 -2.872,17.47 -8.615,22.776c-5.743,5.306 -14.118,7.958 -25.125,7.958l-210,0l0,-21.203l147.052,0c10.229,0 15.344,-4.622 15.344,-13.865c0,-30.597 -5.845,-54.022 -17.536,-70.273c-11.691,-16.252 -27.196,-24.378 -46.516,-24.378c-15.257,0 -28.655,4.508 -40.195,13.523c-11.54,9.016 -20.561,21.96 -27.063,38.833c-6.502,16.873 -9.753,37.037 -9.753,60.492c0,43.872 10.266,76.974 30.799,99.307c20.533,22.333 47.546,33.5 81.039,33.5c21.031,0 39.325,-4.677 54.88,-14.031c15.556,-9.354 26.748,-22.125 33.578,-38.313c2.969,-3.653 5.411,-6.146 7.326,-7.479c1.915,-1.333 3.937,-2 6.065,-2c2.938,0 5.082,1.291 6.432,3.872c1.351,2.582 1.97,5.721 1.859,9.419c-1.174,18.809 -7.769,36.038 -19.786,51.688c-12.017,15.649 -28.122,28.109 -48.315,37.38c-20.193,9.271 -43.218,13.906 -69.076,13.906c-31.069,0 -58.526,-6.501 -82.37,-19.503c-23.844,-13.002 -42.481,-31.285 -55.911,-54.849c-13.431,-23.564 -20.146,-51.166 -20.146,-82.805c0,-32.92 6.497,-62.108 19.49,-87.562c12.993,-25.455 31.546,-45.48 55.659,-60.076c24.113,-14.595 52.768,-21.893 85.966,-21.893c27.938,-0 51.99,5.361 72.159,16.083c20.168,10.722 35.67,25.512 46.505,44.37c10.835,18.858 16.253,40.564 16.253,65.12Z"
style="fill-rule:nonzero;"
/><path
fill={color}
d="M3813.044,487.698c15.937,0 28.422,-4.111 37.453,-12.333c9.031,-8.222 13.547,-18.745 13.547,-31.568c0,-8.125 -1.861,-15.469 -5.583,-22.031c-3.722,-6.562 -10.543,-12.562 -20.464,-17.997c-9.92,-5.436 -24.125,-10.471 -42.615,-15.107c-31.625,-7.188 -56.268,-15.988 -73.93,-26.401c-17.661,-10.413 -30.004,-22.363 -37.029,-35.849c-7.024,-13.486 -10.536,-28.356 -10.536,-44.609c0,-29.288 10.358,-52.604 31.073,-69.948c20.715,-17.344 50.299,-26.016 88.75,-26.016c14.302,0 26.049,1.234 35.24,3.703c9.191,2.469 16.712,4.961 22.562,7.477c5.851,2.516 10.873,3.773 15.068,3.773c4.382,0 7.961,-1.258 10.737,-3.773c2.776,-2.516 5.492,-5.031 8.148,-7.547c2.656,-2.516 5.993,-3.773 10.01,-3.773c2.781,0 5.272,0.905 7.471,2.716c2.2,1.811 4.03,5.162 5.492,10.055l22.667,71.646c2.066,6.226 2.702,11.364 1.909,15.414c-0.793,4.05 -3.287,6.918 -7.482,8.602c-4.097,1.556 -7.667,1.551 -10.708,-0.013c-3.042,-1.564 -5.937,-4.451 -8.687,-8.659c-10.062,-18.646 -20.847,-33.42 -32.354,-44.323c-11.507,-10.903 -23.586,-18.714 -36.237,-23.435c-12.651,-4.72 -25.85,-7.081 -39.596,-7.081c-19.764,0 -34.641,4.182 -44.633,12.547c-9.991,8.365 -14.987,19.563 -14.987,33.594c0,8.41 2.122,16.051 6.367,22.924c4.245,6.873 12.054,13.202 23.427,18.987c11.373,5.785 27.584,11.288 48.633,16.51c27.465,6.378 49.29,14.37 65.474,23.974c16.184,9.604 27.82,21.081 34.909,34.43c7.089,13.349 10.633,28.883 10.633,46.602c0,18.205 -4.623,34.268 -13.87,48.19c-9.247,13.922 -22.141,24.768 -38.682,32.539c-16.542,7.771 -35.79,11.656 -57.745,11.656c-13.83,0 -25.069,-1.468 -33.719,-4.404c-8.649,-2.936 -15.788,-5.848 -21.417,-8.737c-5.628,-2.889 -10.825,-4.333 -15.589,-4.333c-4.257,0 -7.92,1.424 -10.99,4.273c-3.069,2.849 -6.016,5.722 -8.841,8.62c-2.825,2.898 -6.07,4.346 -9.737,4.346c-2.701,0 -5.029,-0.989 -6.982,-2.966c-1.953,-1.977 -3.39,-5.31 -4.31,-9.997l-13.906,-67.13c-1.462,-7.559 -1.775,-13.197 -0.94,-16.914c0.835,-3.717 3.119,-6.322 6.852,-7.815c3.986,-1.587 7.492,-1.376 10.518,0.633c3.026,2.009 6.143,5.641 9.352,10.898c13.649,24.583 28.663,42.171 45.042,52.763c16.378,10.592 33.123,15.888 50.234,15.888Z"
style="fill-rule:nonzero;"
/></g
></svg
>

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { getCurrentYear } from './copyright-year';
describe('getCurrentYear', () => {
it('returns the current calendar year', () => {
expect(getCurrentYear()).toBe(new Date().getFullYear());
});
});

View File

@@ -0,0 +1,7 @@
/**
* Returns the current calendar year (for testing and any server use).
* The client-side footer year is updated by static/copyright-year.js.
*/
export function getCurrentYear(): number {
return new Date().getFullYear();
}

26
src/lib/data/content.ts Normal file
View File

@@ -0,0 +1,26 @@
export const whatWeDoItems = [
'Product-focused frontend and UI architecture for modern web applications, with an emphasis on clarity, scalability, and long-term maintainability.',
'Greenfield product builds and early-stage foundations, getting new projects off the ground quickly with structures designed to grow, not be rewritten.',
'Performance, Core Web Vitals, rendering strategy, and technical SEO optimization focused on real-world user journeys—not just lab scores.',
'Accessibility-first engineering, ensuring WCAG-compliant interfaces with semantic markup, keyboard parity, and inclusive interaction patterns.',
'Modernization and stabilization of existing systems, including refactors, framework upgrades, and untangling overgrown frontend codebases.',
'End-to-end feature delivery with clear ownership and documentation, spanning frontend and supporting backend work without unnecessary complexity.',
];
export const impactItems = [
'Get new products off the ground quickly by establishing durable frontend and platform foundations—clean architecture, clear patterns, and pragmatic defaults designed to scale with teams and traffic.',
'Improve performance, Core Web Vitals, and technical SEO on high-traffic user journeys through rendering strategy, bundle discipline, and careful attention to real-world loading behavior.',
'Build accessibility into core UI systems, not as a retrofit—semantic markup, keyboard parity, and screen reader support baked into reusable components and design patterns.',
'Bring order to complex or aging codebases by simplifying structure, reducing duplication, and clarifying ownership, enabling teams to ship confidently without over-engineering.',
'Design and evolve shared component libraries and UI systems that improve consistency, velocity, and long-term maintainability across multiple teams.',
'Partner closely with product, design, and engineering leadership (including marketing teams and non-technical organizations) to translate goals into shippable systems, balancing speed, quality, and technical risk.',
];
export const howWeWorkItems = [
'Engagements are consulting-led and senior-driven. I work directly with founders, product leaders, marketing teams, and engineering teams—including organizations without in-house technical staff—to establish direction and deliver solutions with a high degree of autonomy.',
'Focused, pragmatic scope. Work is scoped to deliver real progress quickly, with an emphasis on building the right foundation rather than over-engineering for hypothetical futures.',
'Async-friendly, low-friction communication. Clear written updates, documented decisions, and scheduled calls when they add value—not meetings for their own sake.',
'Quality as a default. Accessibility, performance, and maintainability are built into the work from the start, not added later as cleanup.',
"Flexible engagement models. Hourly or fixed-scope work depending on clarity and needs; longer-term engagements welcome when there's ongoing product momentum.",
'Clean handoff. Code, documentation, and context are left in a state where internal teams—or future vendors—can confidently extend the work without dependency.',
];

View File

@@ -0,0 +1,27 @@
export const engagements = [
{
title: 'Atlassian — Senior UI Engineer (Enterprise SaaS)',
description:
'Frontend architecture and feature delivery for Confluence integrations, including React 18 migration work and standardizing end-to-end testing practices.',
},
{
title: 'CarGurus — Principal UI Engineer (Consumer Marketplace)',
description:
'Built and maintained high-traffic frontend systems, improved Core Web Vitals and technical SEO, and developed shared UI platforms used across teams.',
},
{
title: 'The TJX Companies (TJ Maxx) — UI Engineer (Enterprise Retail)',
description:
'Delivered UX improvements for large-scale e-commerce experiences in close partnership with design, QA, and product teams.',
},
{
title: 'Timberland — Senior Interactive Developer (Global Ecommerce)',
description:
'Led global web initiatives across brand and e-commerce platforms, acting as a technical bridge between marketing, design, and engineering.',
},
{
title: 'MFA Boston — Pro Bono Technical Lead (Nonprofit / Fundraising)',
description:
"Designed and built a custom auction application for the MFA's annual Young Patrons fundraiser; subsequently iterated on and supported the platform over multiple years as the event grew, until it concluded during the pandemic.",
},
];

View File

@@ -0,0 +1,53 @@
export const experienceLogos = [
{
src: '/assets/logos/atlassian.svg',
alt: 'Atlassian',
width: 2500,
height: 2500,
showLogo: true,
},
{
src: '/assets/logos/tjx.svg',
alt: 'TJ Maxx (The TJX Companies)',
width: 2500,
height: 621,
showLogo: false,
},
{
src: '/assets/logos/cargurus.svg',
alt: 'CarGurus',
width: 2500,
height: 398,
showLogo: true,
},
{
src: '/assets/logos/timberland.svg',
alt: 'Timberland',
width: 190,
height: 35,
showLogo: true,
},
{
src: '/assets/logos/vf.svg',
alt: 'VF Corporation',
width: 190,
height: 155,
showLogo: true,
},
{
src: '/assets/logos/bottomline.svg',
alt: 'Bottomline Technologies',
width: 2702,
height: 571,
showLogo: true,
},
{
src: '/assets/logos/mfa-boston.svg',
alt: 'Museum of Fine Arts Boston',
width: 572,
height: 88,
showLogo: true,
},
] as const;
export const experienceTextList = experienceLogos.map((l) => l.alt);

View File

@@ -0,0 +1,9 @@
import type { PageMeta } from '$lib/seo';
import { defaultJsonLdGraph } from './json-ld';
export const homeMeta: PageMeta = {
title: 'mifi Ventures — Software Engineering Consulting | Boston, MA',
description:
'Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications. Specializing in frontend architecture, performance optimization, and modern web development.',
jsonLd: defaultJsonLdGraph,
};

150
src/lib/data/json-ld.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Default JSON-LD graph nodes (Organization, Person, WebSite, WebPage, OfferCatalog).
* Used for the home page; other pages can add or override via meta.jsonLd.
*/
const BASE = 'https://mifi.ventures';
export const defaultJsonLdGraph: Record<string, unknown>[] = [
{
'@type': 'Organization',
'@id': `${BASE}/#organization`,
name: 'mifi Ventures, LLC',
legalName: 'mifi Ventures, LLC',
url: `${BASE}/`,
logo: { '@type': 'ImageObject', url: `${BASE}/favicon.svg` },
description:
'Software engineering consulting specializing in product-focused frontend architecture, performance optimization, and accessibility-first engineering.',
founder: { '@id': `${BASE}/#principal` },
address: {
'@type': 'PostalAddress',
addressLocality: 'Boston',
addressRegion: 'MA',
addressCountry: 'US',
},
geo: { '@type': 'GeoCoordinates', latitude: 42.360082, longitude: -71.05888 },
areaServed: { '@type': 'Country', name: 'United States' },
hasOfferCatalog: { '@id': `${BASE}/#services` },
sameAs: ['https://www.linkedin.com/in/the-mifi', 'https://github.com/the-mifi'],
},
{
'@type': 'Person',
'@id': `${BASE}/#principal`,
name: 'Mike Fitzpatrick',
jobTitle: 'Principal Software Engineer and Architect',
description:
'Senior full-stack engineer and architect helping teams ship reliable, accessible, high-performance web products.',
url: `${BASE}/`,
worksFor: { '@id': `${BASE}/#organization` },
knowsAbout: [
'Frontend Architecture',
'UI Architecture',
'React Development',
'Web Performance Optimization',
'Core Web Vitals',
'Technical SEO',
'Web Accessibility (WCAG)',
'Component Libraries',
'Design Systems',
'JavaScript',
'TypeScript',
'Modern Web Development',
'Greenfield Product Development',
'Legacy System Modernization',
'Code Refactoring',
],
sameAs: ['https://www.linkedin.com/in/the-mifi', 'https://github.com/the-mifi'],
},
{
'@type': 'WebSite',
'@id': `${BASE}/#website`,
url: `${BASE}/`,
name: 'mifi Ventures',
description: 'Software Engineering Consulting — Boston, MA',
publisher: { '@id': `${BASE}/#organization` },
potentialAction: {
'@type': 'ReserveAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://cal.mifi.ventures/the-mifi',
},
name: 'Schedule a 30-minute intro call',
},
},
{
'@type': 'WebPage',
'@id': `${BASE}/#webpage`,
url: `${BASE}/`,
name: 'mifi Ventures — Software Engineering Consulting | Boston, MA',
description:
'Boston-based software engineering consulting. Mike Fitzpatrick helps teams build reliable, accessible, high-performance web applications.',
isPartOf: { '@id': `${BASE}/#website` },
about: { '@id': `${BASE}/#organization` },
mainEntity: { '@id': `${BASE}/#organization` },
primaryImageOfPage: { '@type': 'ImageObject', url: `${BASE}/favicon.svg` },
inLanguage: 'en-US',
},
{
'@type': 'OfferCatalog',
'@id': `${BASE}/#services`,
name: 'Software Engineering Consulting Services',
description: 'Consulting services offered by mifi Ventures',
numberOfItems: 6,
itemListElement: [
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Frontend and UI Architecture',
description:
'Product-focused frontend and UI architecture for modern web applications, with an emphasis on clarity, scalability, and long-term maintainability.',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Greenfield Product Development',
description:
'Greenfield product builds and early-stage foundations, getting new projects off the ground quickly with structures designed to grow, not be rewritten.',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Performance Optimization',
description:
'Performance, Core Web Vitals, rendering strategy, and technical SEO optimization focused on real-world user journeys.',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Accessibility Engineering',
description:
'Accessibility-first engineering, ensuring WCAG-compliant interfaces with semantic markup, keyboard parity, and inclusive interaction patterns.',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'System Modernization',
description:
'Modernization and stabilization of existing systems, including refactors, framework upgrades, and untangling overgrown frontend codebases.',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'End-to-End Feature Delivery',
description:
'End-to-end feature delivery with clear ownership and documentation, spanning frontend and supporting backend work without unnecessary complexity.',
},
},
],
},
];

53
src/lib/seo.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* SEO / meta: site-wide defaults, page-meta type, and merge helper.
* Layout renders <head> from merged meta; each route can export meta from +page.ts.
*/
export const SEO_DEFAULTS = {
siteName: 'mifi Ventures',
baseUrl: 'https://mifi.ventures',
defaultOgImage: '/assets/og-image.png',
ogImageWidth: 1200,
ogImageHeight: 630,
locale: 'en_US',
twitterCard: 'summary_large_image' as const,
themeColorLight: '#0052cc',
themeColorDark: '#4da6ff',
} as const;
export interface PageMeta {
title: string;
description?: string;
canonical?: string;
ogImage?: string;
ogType?: string;
twitterTitle?: string;
twitterDescription?: string;
/** JSON-LD graph nodes (merged with defaults in layout) */
jsonLd?: Record<string, unknown>[];
}
export interface MergedMeta extends PageMeta {
canonical: string;
ogImage: string;
ogImageAlt: string;
jsonLdGraph: Record<string, unknown>[];
}
/** Merge page meta with site defaults for rendering. */
export function mergeMeta(meta: PageMeta, path: string = '/'): MergedMeta {
const baseUrl = SEO_DEFAULTS.baseUrl;
const canonical = meta.canonical ?? `${baseUrl}${path === '/' ? '' : path}`;
const ogImage = meta.ogImage?.startsWith('http')
? meta.ogImage
: `${baseUrl}${meta.ogImage?.startsWith('/') ? meta.ogImage : SEO_DEFAULTS.defaultOgImage}`;
const ogImageAlt = meta.title;
const jsonLdGraph = meta.jsonLd ?? [];
return {
...meta,
canonical,
ogImage,
ogImageAlt,
jsonLdGraph,
};
}

147
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { page } from '$app/state';
import { mergeMeta, SEO_DEFAULTS } from '$lib/seo';
import { homeMeta } from '$lib/data/home-meta';
import '../app.css';
let { children } = $props();
const meta = $derived(page.data?.meta ?? homeMeta);
const path = $derived(page.url?.pathname ?? '/');
const merged = $derived(mergeMeta(meta, path));
const jsonLdScript = $derived(
merged.jsonLdGraph.length > 0
? JSON.stringify({
'@context': 'https://schema.org',
'@graph': merged.jsonLdGraph,
})
: '',
);
const jsonLdHtml = $derived(
jsonLdScript
? '<script type="application/ld+json">' + jsonLdScript + '</scr' + 'ipt>'
: '',
);
</script>
<svelte:head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-36F29PDKRT"></script>
<script defer src="/assets/js/ga-init.js"></script>
<title>{merged.title}</title>
<meta name="description" content={merged.description ?? ''} />
<link rel="canonical" href={merged.canonical} />
<link
rel="preload"
href="/assets/fonts/fraunces-v38-latin-600.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/fraunces-v38-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-italic.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-500.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-600.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/inter-v20-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<meta
name="robots"
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
/>
<meta name="author" content="Mike Fitzpatrick" />
<meta name="geo.region" content="US-MA" />
<meta name="geo.placename" content="Boston" />
<meta name="geo.position" content="42.360082;-71.058880" />
<meta name="ICBM" content="42.360082, -71.058880" />
<meta
name="theme-color"
content={SEO_DEFAULTS.themeColorLight}
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content={SEO_DEFAULTS.themeColorDark}
media="(prefers-color-scheme: dark)"
/>
<meta property="og:type" content={merged.ogType ?? 'website'} />
<meta property="og:url" content={merged.canonical} />
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
<meta property="og:title" content={merged.twitterTitle ?? merged.title} />
<meta
property="og:description"
content={merged.twitterDescription ?? merged.description ?? ''}
/>
<meta property="og:image" content={merged.ogImage} />
<meta property="og:image:width" content={String(SEO_DEFAULTS.ogImageWidth)} />
<meta property="og:image:height" content={String(SEO_DEFAULTS.ogImageHeight)} />
<meta property="og:image:alt" content={merged.ogImageAlt} />
<meta property="og:locale" content={SEO_DEFAULTS.locale} />
<meta name="twitter:card" content={SEO_DEFAULTS.twitterCard} />
<meta name="twitter:url" content={merged.canonical} />
<meta name="twitter:title" content={merged.twitterTitle ?? merged.title} />
<meta
name="twitter:description"
content={merged.twitterDescription ?? merged.description ?? ''}
/>
<meta name="twitter:image" content={merged.ogImage} />
<meta name="twitter:image:alt" content={merged.ogImageAlt} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
{#if jsonLdHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- static trusted JSON-LD -->
{@html jsonLdHtml}
{/if}
<script src="/assets/js/copyright-year.js" defer></script>
<script src="/assets/js/mobile-menu-helper.js" defer></script>
</svelte:head>
<a href="#main" class="skip-link">Skip to main content</a>
{@render children()}

3
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,3 @@
export const prerender = true;
export const ssr = true;
export const csr = false;

23
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,23 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Hero from '$lib/components/Hero.svelte';
import ExperienceSection from '$lib/components/ExperienceSection.svelte';
import WhatWeDo from '$lib/components/WhatWeDo.svelte';
import ImpactSection from '$lib/components/ImpactSection.svelte';
import HowWeWork from '$lib/components/HowWeWork.svelte';
import EngagementsSection from '$lib/components/EngagementsSection.svelte';
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import Footer from '$lib/components/Footer.svelte';
</script>
<Navigation />
<Hero />
<main id="main">
<ExperienceSection />
<WhatWeDo />
<ImpactSection />
<HowWeWork />
<EngagementsSection />
<ScheduleSection />
</main>
<Footer />

6
src/routes/+page.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { PageLoad } from './$types';
import { homeMeta } from '$lib/data/home-meta';
export const load: PageLoad = () => {
return { meta: homeMeta };
};

View File

@@ -0,0 +1,11 @@
# Canonical URL for this file (recommended for validation)
Canonical: https://mifi.ventures/.well-known/security.txt
# Contact for reporting security vulnerabilities (required)
Contact: mailto:security@mifi.holdings
# Optional: link to your vulnerability disclosure policy when you have one
# Policy: https://mifi.ventures/security
# Date after which this file is considered stale (required; RFC 3339; max 1 year recommended)
Expires: 2027-02-01T00:00:00.000Z

20
static/404.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404 Not Found — mifi Ventures</title>
<meta name="theme-color" content="#0052cc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#4da6ff" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/error-pages.css">
</head>
<body class="error-page">
<main>
<div class="emoji" aria-hidden="true">🔍</div>
<h1>404 Not Found</h1>
<p>This page went off to find itself. Were not sure its coming back.</p>
<p><a href="/">Back to mifi Ventures →</a></p>
</main>
</body>
</html>

20
static/410.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>410 Gone — mifi Ventures</title>
<meta name="theme-color" content="#0052cc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#4da6ff" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/error-pages.css">
</head>
<body class="error-page">
<main>
<div class="emoji" aria-hidden="true">👋</div>
<h1>410 Gone</h1>
<p>This page has left the building. Weve moved on—and so should you.</p>
<p><a href="/">Back to mifi Ventures →</a></p>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,102 @@
/**
* Shared styles for static error pages (404, 410).
* Uses same theme tokens and local fonts as the main site.
* Linked from 404.html and 410.html; Critters inlines critical CSS at build time.
*/
/* Theme: light (default) — matches src/app.css :root */
:root {
color-scheme: light dark;
--ep-bg: #ffffff;
--ep-bg-alt: #faf9ff;
--ep-text: #14121a;
--ep-text-secondary: #3f3a4a;
--ep-primary: #6d28d9;
--ep-primary-hover: #5b21b6;
--ep-font: 'Inter', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
--ep-font-heading: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;
--ep-size-base: 18px;
--ep-line-height: 1.75;
}
/* Theme: dark — matches src/app.css @media (prefers-color-scheme: dark) */
@media (prefers-color-scheme: dark) {
:root {
--ep-bg: #0b0b12;
--ep-bg-alt: #121226;
--ep-text: #f3f2ff;
--ep-text-secondary: #c9c6e4;
--ep-primary: #a78bfa;
--ep-primary-hover: #c4b5fd;
}
}
/* Local fonts — same paths as +layout.svelte preloads */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/inter-v20-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/assets/fonts/inter-v20-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/fonts/fraunces-v38-latin-600.woff2') format('woff2');
}
/* Error page layout */
.error-page {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--ep-font);
font-size: var(--ep-size-base);
line-height: var(--ep-line-height);
color: var(--ep-text);
background-color: var(--ep-bg-alt);
}
.error-page main {
text-align: center;
padding: 2rem 1.5rem;
max-width: 28rem;
}
.error-page .emoji {
font-size: 4rem;
margin-bottom: 0.5rem;
}
.error-page h1 {
font-family: var(--ep-font-heading);
font-size: 2rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--ep-text);
}
.error-page p {
margin: 0 0 1.5rem;
color: var(--ep-text-secondary);
}
.error-page a {
color: var(--ep-primary);
font-weight: 500;
text-decoration: none;
}
.error-page a:hover {
color: var(--ep-primary-hover);
text-decoration: underline;
}
.error-page a:focus-visible {
outline: 2px solid var(--ep-primary);
outline-offset: 2px;
}

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 2134 2134" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M2133.333,400l0,1333.333c0,220.766 -179.234,400 -400,400l-1333.333,0c-220.766,0 -400,-179.234 -400,-400l0,-1333.333c0,-220.766 179.234,-400 400,-400l1333.333,0c220.766,0 400,179.234 400,400Z" style="fill:#0b0b0f;"/><g><path d="M178.684,1755.333l0,-916.655l208.714,0l0,211.244l-24.034,-34.153c16.304,-66.339 49.965,-115.812 100.984,-148.419c51.019,-32.607 110.822,-48.911 179.41,-48.911c74.772,0 140.899,19.466 198.384,58.398c57.484,38.932 94.659,90.724 111.525,155.376l-63.247,5.481c28.391,-73.928 70.625,-128.953 126.704,-165.074c56.079,-36.121 120.801,-54.181 194.167,-54.181c64.933,0 122.98,14.617 174.139,43.851c51.16,29.234 91.567,69.852 121.223,121.855c29.656,52.003 44.483,112.157 44.483,180.464l0,590.724l-221.363,0l0,-538.018c0,-40.759 -7.309,-75.615 -21.926,-104.568c-14.617,-28.953 -34.996,-51.511 -61.138,-67.674c-26.142,-16.163 -57.344,-24.245 -93.605,-24.245c-35.137,0 -66.128,8.082 -92.973,24.245c-26.845,16.163 -47.646,38.791 -62.403,67.885c-14.758,29.093 -22.136,63.879 -22.136,104.357l0,538.018l-221.363,0l0,-538.018c0,-40.759 -7.309,-75.615 -21.926,-104.568c-14.617,-28.953 -34.996,-51.511 -61.138,-67.674c-26.142,-16.163 -57.344,-24.245 -93.605,-24.245c-35.418,0 -66.479,8.082 -93.183,24.245c-26.704,16.163 -47.435,38.791 -62.193,67.885c-14.758,29.093 -22.136,63.879 -22.136,104.357l0,538.018l-221.363,0Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1733.286,1755.333l0,-916.655l221.363,0l0,916.655l-221.363,0Zm0,-1020.379l0,-236.121l221.363,0l0,236.121l-221.363,0Z" style="fill:#fff;fill-rule:nonzero;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
(function () {
'use strict';
var el = document.getElementById('copyright-year');
if (el) el.textContent = new Date().getFullYear();
})();

View File

@@ -0,0 +1,8 @@
window.dataLayer = window.dataLayer || [];
function gtag(){ window.dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "G-36F29PDKRT", {
// optional, but often helpful:
anonymize_ip: true,
});

View File

@@ -0,0 +1,88 @@
const MOBILE_BREAKPOINT_PX = 768;
/** All focusable elements inside the menu (links). */
const getMenuFocusables = (menu) =>
menu.querySelectorAll('a[href]');
const mobileMenuHelper = () => {
const mobileMenu = document.getElementById('nav-menu');
const mobileMenuToggleButton = document.getElementById('nav-toggle-button');
const mobileMenuToggle = document.querySelector('.nav-toggle-input');
const menuItems = mobileMenu.querySelectorAll('.nav-item');
const isMobile = () => window.innerWidth <= MOBILE_BREAKPOINT_PX;
const syncMenuAriaHidden = () => {
if (isMobile()) {
const hidden = !mobileMenuToggle.checked;
mobileMenu.setAttribute(
'aria-hidden',
hidden ? 'true' : 'false',
);
// inert removes the subtree from the a11y tree and makes descendants non-focusable
if (hidden) {
mobileMenu.setAttribute('inert', '');
} else {
mobileMenu.removeAttribute('inert');
}
setMenuFocusablesTabIndex();
} else {
mobileMenu.removeAttribute('aria-hidden');
mobileMenu.removeAttribute('inert');
getMenuFocusables(mobileMenu).forEach((el) => {
el.removeAttribute('tabindex');
});
}
};
const setMenuFocusablesTabIndex = () => {
const focusables = getMenuFocusables(mobileMenu);
const hidden = !mobileMenuToggle.checked;
focusables.forEach((el) => {
if (hidden) {
el.setAttribute('tabindex', '-1');
} else {
el.removeAttribute('tabindex');
}
});
};
const syncButtonAriaExpanded = () => {
mobileMenuToggleButton.setAttribute(
'aria-expanded',
mobileMenuToggle.checked ? 'true' : 'false',
);
};
menuItems.forEach((item) => {
item.addEventListener('click', () => {
mobileMenuToggle.checked = false;
syncButtonAriaExpanded();
syncMenuAriaHidden();
});
});
mobileMenuToggle.addEventListener('change', () => {
syncButtonAriaExpanded();
syncMenuAriaHidden();
});
mobileMenuToggleButton.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
mobileMenuToggle.checked = !mobileMenuToggle.checked;
// Programmatic .checked change does not fire 'change'; sync state so menu is focusable when open
syncButtonAriaExpanded();
syncMenuAriaHidden();
}
});
window.addEventListener('resize', () => {
syncMenuAriaHidden();
});
syncMenuAriaHidden();
syncButtonAriaExpanded();
};
document.addEventListener('DOMContentLoaded', mobileMenuHelper);

Some files were not shown because too many files have changed in this diff Show More