Compare commits
66 Commits
12fb8c0335
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
ddcfc8d8d2
|
|||
|
dac44e1b12
|
|||
|
5eb34a0c59
|
|||
|
ef40d25e6a
|
|||
|
718165aa23
|
|||
|
c15adf8e3c
|
|||
|
72f0eab718
|
|||
|
beffd5f4e8
|
|||
|
0c4823d263
|
|||
|
c912cde7f5
|
|||
|
c8d7c168c8
|
|||
|
a2242809b2
|
|||
|
4d43018773
|
|||
|
1f8e5c4c3e
|
|||
|
a5989b03b1
|
|||
|
4ad45d5625
|
|||
|
e6f2e92083
|
|||
|
4503298213
|
|||
| 80a4717b14 | |||
|
cf6ff70cfb
|
|||
|
d7e427f164
|
|||
|
a9c25917f0
|
|||
| 7012f0fdd2 | |||
|
66640fa535
|
|||
|
5e0e211f80
|
|||
|
d66e9f7cb8
|
|||
|
39ba54e254
|
|||
|
56b5740393
|
|||
|
3a94a50def
|
|||
|
1349488827
|
|||
|
c963e34766
|
|||
|
f91531b5fa
|
|||
|
b0146992c2
|
|||
|
7a01cbd2c9
|
|||
|
fe8cf26a29
|
|||
|
a519df1016
|
|||
|
ceeb76663b
|
|||
|
14edd403eb
|
|||
|
9f43bf7879
|
|||
|
2d0a4935a5
|
|||
|
e4929a4699
|
|||
|
af705efc17
|
|||
|
f73b7822a5
|
|||
| c094cc29ea | |||
|
4cfe2c5da0
|
|||
| 864c9a735c | |||
|
11ff3dcff3
|
|||
|
0f423f0677
|
|||
| aaea4169f9 | |||
| e76f0f79e8 | |||
|
6f2a720479
|
|||
|
dfa18c8560
|
|||
|
3a940e9da1
|
|||
|
0266d472d9
|
|||
|
e34e0e4c7b
|
|||
|
81abdf4539
|
|||
|
4bcce26a74
|
|||
|
52c0fd5e2d
|
|||
| 911093f0b6 | |||
|
40b770f8b5
|
|||
|
d045e56389
|
|||
|
02e4319459
|
|||
|
288a1b8f7a
|
|||
|
6cbb947932
|
|||
|
c0f812a155
|
|||
|
b58077d6e3
|
@@ -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.
|
||||
|
||||
@@ -3,19 +3,28 @@
|
||||
"dockerFile": "Dockerfile",
|
||||
"workspaceFolder": "/workspaces/mifi-ventures-landing",
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/mifi-ventures-landing,type=bind",
|
||||
"forwardPorts": [3000],
|
||||
"runArgs": ["-u", "root"],
|
||||
"remoteUser": "root",
|
||||
"postCreateCommand": "pnpm install",
|
||||
"forwardPorts": [5173, 4173],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Site",
|
||||
"5173": {
|
||||
"label": "Dev (Vite)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4173": {
|
||||
"label": "Preview (Vite)",
|
||||
"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"
|
||||
"esbenp.prettier-vscode",
|
||||
"rvest.vs-code-prettier-eslint",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"svelte.svelte-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"files.associations": {
|
||||
@@ -31,6 +40,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"remoteUser": "node"
|
||||
}
|
||||
}
|
||||
|
||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
# Preview-only 410 path artifacts (copy-410-paths.mjs).
|
||||
# Deploy uses pnpm run build (no copy step); nginx serves 410 via error_page.
|
||||
# These dirs exist only when running build-preview for local serve dist.
|
||||
dist/pt
|
||||
dist/feed
|
||||
dist/2024
|
||||
dist/category
|
||||
dist/comments
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||
|
||||
4
.husky/pre-commit
Normal file
4
.husky/pre-commit
Normal file
@@ -0,0 +1,4 @@
|
||||
# Only run CI tests when files under src/ are changed
|
||||
if git diff --cached --name-only | grep -q '^src/'; then
|
||||
pnpm run test:ci
|
||||
fi
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal 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" } }]
|
||||
}
|
||||
106
.woodpecker.yml
106
.woodpecker.yml
@@ -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
151
.woodpecker/ci.yaml
Normal 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]
|
||||
196
.woodpecker/deploy.yaml
Normal file
196
.woodpecker/deploy.yaml
Normal file
@@ -0,0 +1,196 @@
|
||||
# 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
|
||||
- apk add --no-cache jq
|
||||
- APP_VERSION=$(jq -r .version package.json | tr -d '\r\n' | sed 's/^v//')
|
||||
- echo "=== Building Docker image ==="
|
||||
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
|
||||
- 'echo "Registry repo: $REGISTRY_REPO"'
|
||||
- 'echo "App version: $APP_VERSION"'
|
||||
- |
|
||||
docker build \
|
||||
--tag "$REGISTRY_REPO:$CI_COMMIT_SHA" \
|
||||
--tag "$REGISTRY_REPO:latest" \
|
||||
--tag "$REGISTRY_REPO:$APP_VERSION" \
|
||||
--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
|
||||
- apk add --no-cache jq
|
||||
- APP_VERSION=$(jq -r .version package.json | tr -d '\r\n' | sed 's/^v//')
|
||||
- echo "=== Pushing to registry ==="
|
||||
- 'echo "Registry: $REGISTRY_URL"'
|
||||
- 'echo "Repository: $REGISTRY_REPO"'
|
||||
- 'echo "App version: $APP_VERSION"'
|
||||
- |
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
|
||||
-u "$REGISTRY_USERNAME" \
|
||||
--password-stdin
|
||||
- 'docker push $REGISTRY_REPO:$CI_COMMIT_SHA'
|
||||
- 'docker push $REGISTRY_REPO:latest'
|
||||
- 'docker push $REGISTRY_REPO:$APP_VERSION'
|
||||
- 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]
|
||||
41
.woodpecker/update-e2e-snapshots.yaml
Normal file
41
.woodpecker/update-e2e-snapshots.yaml
Normal 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
75
AGENTS.md
Normal 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/beasties.mjs` → `node scripts/generate-sitemap.mjs` → `node scripts/minify-static-js.mjs`. Output is `dist/` (static files only). Deploy uses this; no 410 path copies. For local preview with 410 URLs working, use `pnpm run build-preview` (adds `copy-410-paths.mjs`). The 410 path dirs are in `.dockerignore` so they are never included in the image.
|
||||
- **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/beasties.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` | Run by `build-preview` only: 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. Production uses `build` (no copy); nginx returns 410 via explicit location blocks and `error_page 410 /410.html`. |
|
||||
| `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 Beasties on all `dist/*.html`. Beasties 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; Beasties 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**: Deploy runs `pnpm run build` (no 410 path copies); the image stays minimal and nginx serves 410 via `error_page 410 /410.html`. For local preview with 410 URLs working, run `pnpm run build-preview` then `serve dist`; the copied `index.html` files under each 410 path are in `.dockerignore` so they are never copied into the image. If you add or remove 410 paths, update `nginx.conf` and the `PATHS` array in `scripts/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 Beasties for new HTML**
|
||||
Any new `.html` in `static/` is copied to `dist/` and **must** be processed by Beasties (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 Beasties).
|
||||
|
||||
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 Beasties can inline). Ensure `scripts/beasties.mjs` runs over all `dist/*.html` (it already does).
|
||||
- **Change nginx behavior**: `nginx.conf` (e.g. cache headers, `error_page`, `try_files`).
|
||||
10
Dockerfile
10
Dockerfile
@@ -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
|
||||
|
||||
|
||||
198
README.md
198
README.md
@@ -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,27 @@ 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 |
|
||||
| `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 test:ci` | Run same tests as CI: lint, lint:css, unit tests, e2e (used by pre-commit hook) |
|
||||
| `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) |
|
||||
|
||||
A **pre-commit hook** (Husky) runs `pnpm run test:ci` so the same tests as CI run before each commit. Skip with `git commit --no-verify` if needed.
|
||||
|
||||
### Option 1: pnpm dev (recommended for editing)
|
||||
|
||||
@@ -39,35 +49,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 +77,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 you’re 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.
|
||||
|
||||
**Running e2e locally:** `pnpm run test:e2e` mirrors CI when Docker is available (runs tests in the same Playwright image). Without Docker (e.g. inside the devcontainer), it runs in the current environment. The config uses Linux snapshot paths so baselines stay consistent; run in Docker or devcontainer for matching rendering.
|
||||
|
||||
### Option 4: Docker (Production-like Test)
|
||||
|
||||
@@ -91,53 +106,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 route’s 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/beasties.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 +159,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`
|
||||
|
||||
2. **Push** — Pushes images to private Docker registry
|
||||
3. **Deploy** — SSH to Linode VPS, pulls latest image, restarts container with health checks
|
||||
|
||||
@@ -159,14 +175,16 @@ 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) |
|
||||
| `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
|
||||
@@ -178,7 +196,7 @@ 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` |
|
||||
@@ -191,22 +209,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"
|
||||
|
||||
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 +238,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 +261,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 +274,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 +310,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 +321,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 +331,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 +339,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 +347,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,6 +358,7 @@ 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
|
||||
@@ -337,7 +369,9 @@ Minimum size: 256 bytes (avoids compressing tiny files)
|
||||
- **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 +381,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 +393,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 +401,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 +411,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 +436,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,12 +444,14 @@ 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)
|
||||
@@ -418,23 +459,27 @@ This site is built to meet **WCAG 2.2 Level AAA** standards wherever applicable
|
||||
- **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 +488,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
|
||||
|
||||
164
SEO-CHECKLIST.md
164
SEO-CHECKLIST.md
@@ -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]
|
||||
48
build.mjs
48
build.mjs
@@ -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);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Deployment Guide
|
||||
|
||||
Woodpecker builds the site, pushes the image to **Gitea’s 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 **Gitea’s 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 you’re 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 you’re 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 repo’s registry) |
|
||||
| `portainer_webhook_url` | Portainer stack webhook URL (from step 2) |
|
||||
|
||||
`REGISTRY_URL` and `REGISTRY_REPO` are set in `.woodpecker.yml`; you don’t need to add them anywhere.
|
||||
`REGISTRY_URL` and `REGISTRY_REPO` are set in `.woodpecker/deploy.yaml`; you don’t 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
46
eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
BIN
logos/avatar-playground-2.af
Normal file
BIN
logos/avatar-playground-2.af
Normal file
Binary file not shown.
BIN
logos/avatar-playground.af
Normal file
BIN
logos/avatar-playground.af
Normal file
Binary file not shown.
BIN
logos/avatar.af
BIN
logos/avatar.af
Binary file not shown.
BIN
logos/exports/mifi-avatar.png
Normal file
BIN
logos/exports/mifi-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
logos/exports/nifi-avatar-reverse.png
Normal file
BIN
logos/exports/nifi-avatar-reverse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
logos/favicon cutout block (new).af
Normal file
BIN
logos/favicon cutout block (new).af
Normal file
Binary file not shown.
BIN
logos/favicon.af
BIN
logos/favicon.af
Binary file not shown.
38
nginx.conf
38
nginx.conf
@@ -103,19 +103,47 @@ http {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Default location
|
||||
# Default location: try $uri.html so /services serves services.html, /services/foo serves services/foo.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri.html $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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
package.json
64
package.json
@@ -1,17 +1,65 @@
|
||||
{
|
||||
"name": "mifi-ventures-landing",
|
||||
"version": "1.0.0",
|
||||
"version": "4.0.3",
|
||||
"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",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"preview": "npx serve dist",
|
||||
"dev": "live-server site --port=3000 --open=/"
|
||||
"build": "vite build && node scripts/beasties.mjs && node scripts/generate-sitemap.mjs && node scripts/minify-static-js.mjs",
|
||||
"build-preview": "pnpm run build && 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}\" \"static/**/*.{css,html,js}\"",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:css": "stylelint \"src/**/*.css\" \"src/**/*.svelte\" \"static/**/*.css\"",
|
||||
"lint:css:fix": "stylelint \"src/**/*.css\" \"src/**/*.svelte\" \"static/**/*.css\" --fix",
|
||||
"preview": "serve dist -p 4173",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"test:e2e:update-snapshots": "bash scripts/update-e2e-snapshots.sh",
|
||||
"test:all": "vitest run && playwright test",
|
||||
"test:ci": "pnpm run lint && pnpm run lint:css && pnpm exec svelte-kit sync && pnpm test && pnpm run test:e2e && pnpm install --config.confirmModulesPurge=false",
|
||||
"test:watch": "vitest",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"critters": "^0.0.24",
|
||||
"live-server": "^1.2.2"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/gtag.js": "^0.0.20",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"beasties": "^0.4.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.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": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^0.577.0"
|
||||
}
|
||||
}
|
||||
|
||||
29
playwright.config.ts
Normal file
29
playwright.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests',
|
||||
testMatch: /.*\.spec\.(ts|js)/,
|
||||
// Use linux in snapshot paths so local (darwin) runs compare against the same snapshots as CI.
|
||||
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-linux{ext}',
|
||||
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-desktop', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'chromium-mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: 'pnpm run preview',
|
||||
url: 'http://localhost:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
5523
pnpm-lock.yaml
generated
5523
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
17
postcss.config.js
Normal file
17
postcss.config.js
Normal 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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
12
scripts/410-paths.mjs
Normal file
12
scripts/410-paths.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* URL paths that return 410 Gone. Shared by copy-410-paths.mjs and generate-sitemap.mjs.
|
||||
* Keep in sync with nginx.conf location blocks.
|
||||
*/
|
||||
export const PATHS = [
|
||||
'2024/02/18/hello-world',
|
||||
'pt',
|
||||
'feed',
|
||||
'category/uncategorized/feed',
|
||||
'category/uncategorized',
|
||||
'comments/feed',
|
||||
];
|
||||
69
scripts/beasties.mjs
Normal file
69
scripts/beasties.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build: inline critical CSS in dist/*.html (SvelteKit adapter-static output).
|
||||
* Runs after vite build; Beasties reads/writes relative to dist/.
|
||||
*
|
||||
* Beasties with preload:'default' adds preload tags; same options as legacy Critters.
|
||||
*/
|
||||
|
||||
import Beasties from 'beasties';
|
||||
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');
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(DIST)) {
|
||||
console.error('dist/ not found. Run vite build first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const beasties = new Beasties({
|
||||
path: DIST,
|
||||
preload: 'default',
|
||||
noscriptFallback: true,
|
||||
pruneSource: false,
|
||||
logLevel: 'warn',
|
||||
});
|
||||
|
||||
const rootFiles = fs.readdirSync(DIST)
|
||||
.filter((f) => f.endsWith('.html'))
|
||||
.map((f) => path.join(DIST, f));
|
||||
|
||||
const servicesDir = path.join(DIST, 'services');
|
||||
const serviceFiles = [];
|
||||
|
||||
if (fs.existsSync(servicesDir)) {
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
||||
serviceFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(servicesDir);
|
||||
}
|
||||
|
||||
const files = [...rootFiles, ...serviceFiles];
|
||||
|
||||
for (const filePath of files) {
|
||||
let html = fs.readFileSync(filePath, 'utf8');
|
||||
html = await beasties.process(html);
|
||||
fs.writeFileSync(filePath, html, 'utf8');
|
||||
console.log('✓ Critical CSS inlined → dist/' + path.relative(DIST, filePath));
|
||||
}
|
||||
|
||||
console.log('Critical CSS step complete.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
32
scripts/copy-410-paths.mjs
Normal file
32
scripts/copy-410-paths.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/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';
|
||||
|
||||
import { PATHS } from './410-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIST = path.join(__dirname, '..', 'dist');
|
||||
const SOURCE = path.join(DIST, '410.html');
|
||||
|
||||
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();
|
||||
99
scripts/generate-sitemap.mjs
Normal file
99
scripts/generate-sitemap.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build: generate sitemap.xml from prerendered pages in dist/.
|
||||
* Scans for index.html (root and under each path), excludes 410 paths.
|
||||
* Run after vite build and beasties, before copy-410-paths so 410 dirs don't exist yet.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { PATHS as PATHS_410 } from './410-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIST = path.join(__dirname, '..', 'dist');
|
||||
|
||||
/** Must match src/lib/seo.ts SEO_DEFAULTS.baseUrl */
|
||||
const BASE_URL = 'https://mifi.ventures';
|
||||
|
||||
const EXCLUDE_FILES = new Set(['404.html', '410.html']);
|
||||
const excludeSet = new Set(PATHS_410);
|
||||
|
||||
/**
|
||||
* adapter-static emits path.html or path/index.html. Walk dist and collect
|
||||
* every .html that represents a page (exclude 404/410 and 410-gone paths).
|
||||
*/
|
||||
function findPages(dir, basePath = '') {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const pages = [];
|
||||
|
||||
for (const e of entries) {
|
||||
const rel = basePath ? `${basePath}/${e.name}` : e.name;
|
||||
|
||||
if (e.isDirectory()) {
|
||||
const indexPath = path.join(dir, e.name, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
if (!excludeSet.has(rel)) {
|
||||
pages.push({ path: rel, indexPath });
|
||||
}
|
||||
} else {
|
||||
pages.push(...findPages(path.join(dir, e.name), rel));
|
||||
}
|
||||
} else if (e.name.endsWith('.html') && !EXCLUDE_FILES.has(e.name)) {
|
||||
const urlPath = e.name === 'index.html'
|
||||
? basePath
|
||||
: (basePath ? `${basePath}/${e.name.slice(0, -5)}` : e.name.slice(0, -5));
|
||||
if (!excludeSet.has(urlPath)) {
|
||||
pages.push({ path: urlPath, indexPath: path.join(dir, e.name) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const indexHtml = path.join(DIST, 'index.html');
|
||||
if (!fs.existsSync(indexHtml)) {
|
||||
console.error('dist/index.html not found. Run build first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pages = findPages(DIST).sort((a, b) => {
|
||||
if (a.path === '') return -1;
|
||||
if (b.path === '') return 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
const urlElements = [];
|
||||
|
||||
for (const { path: pagePath, indexPath } of pages) {
|
||||
const loc = pagePath ? `${BASE_URL}/${pagePath}` : BASE_URL;
|
||||
const stat = fs.statSync(indexPath);
|
||||
const lastmod = stat.mtime.toISOString().slice(0, 10);
|
||||
|
||||
urlElements.push(
|
||||
` <url>\n <loc>${escapeXml(loc)}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`
|
||||
);
|
||||
}
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urlElements.join('\n')}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(DIST, 'sitemap.xml'), xml, 'utf8');
|
||||
console.log('✓ sitemap.xml generated with', pages.length, 'URLs.');
|
||||
}
|
||||
|
||||
main();
|
||||
45
scripts/minify-static-js.mjs
Normal file
45
scripts/minify-static-js.mjs
Normal 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 beasties). 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);
|
||||
});
|
||||
28
scripts/run-e2e-in-docker.sh
Executable file
28
scripts/run-e2e-in-docker.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run Playwright e2e tests in the same Docker image as CI (and as snapshot generation).
|
||||
# Use when running locally on macOS/Windows so tests mirror CI; in CI or devcontainer use pnpm test:e2e directly.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="${BASH_SOURCE%/*}"
|
||||
PROJECT_ROOT="${SCRIPT_DIR}/.."
|
||||
cd "$PROJECT_ROOT"
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
|
||||
PLAYWRIGHT_IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.58.0-noble}"
|
||||
echo "Running e2e tests in 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 --no-frozen-lockfile || pnpm install
|
||||
pnpm run build
|
||||
npx serve dist -p 4173 &
|
||||
sleep 2
|
||||
pnpm exec playwright test
|
||||
pnpm install --config.confirmModulesPurge=false
|
||||
'
|
||||
21
scripts/run-e2e.sh
Executable file
21
scripts/run-e2e.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run Playwright e2e tests. Mirrors CI when possible.
|
||||
# - In CI: run playwright test (pipeline already built and started serve).
|
||||
# - Local with Docker: run tests in same Playwright image as CI (run-e2e-in-docker.sh).
|
||||
# - Local without Docker (e.g. devcontainer): build and run playwright test (webServer in config).
|
||||
set -e
|
||||
|
||||
if [ -n "$CI" ]; then
|
||||
pnpm exec playwright test
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
bash "$(dirname "$0")/run-e2e-in-docker.sh"
|
||||
pnpm install --config.confirmModulesPurge=false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No Docker: run in current environment (e.g. devcontainer; same image as CI)
|
||||
pnpm run build
|
||||
pnpm exec playwright test
|
||||
44
scripts/update-e2e-snapshots.sh
Executable file
44
scripts/update-e2e-snapshots.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/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"
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
|
||||
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
|
||||
'
|
||||
pnpm install --config.confirmModulesPurge=false
|
||||
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 |
BIN
site/favicon.ico
BIN
site/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -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 |
768
site/index.html
768
site/index.html
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
1064
site/styles.css
1064
site/styles.css
File diff suppressed because it is too large
Load Diff
809
src/app.css
Normal file
809
src/app.css
Normal file
@@ -0,0 +1,809 @@
|
||||
/* ========================================
|
||||
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-xs: 14px;
|
||||
--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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Local Font Faces
|
||||
======================================== */
|
||||
|
||||
@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: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/inter-v20-latin-700.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');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Fraunces;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/fraunces-v38-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
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;
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
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
|
||||
======================================== */
|
||||
|
||||
/* Avoid in-page anchor targets sitting under sticky nav */
|
||||
[id] {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-md);
|
||||
|
||||
&.narrow {
|
||||
max-width: var(--max-narrow-width);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
&.small {
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: var(--font-size-xs);
|
||||
min-height: 36px;
|
||||
min-width: fit-content;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&.ordered {
|
||||
list-style: decimal;
|
||||
padding-left: var(--space-xl);
|
||||
|
||||
& li {
|
||||
padding-left: var(--space-sm);
|
||||
|
||||
&::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Services Landing: Card Grid, Engagements, Ideal
|
||||
======================================== */
|
||||
|
||||
.engagements-list {
|
||||
margin: var(--space-lg) 0;
|
||||
|
||||
& dt {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
margin-top: var(--space-lg);
|
||||
margin-bottom: var(--space-xs);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: 0 0 0 var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
.services-intro {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
|
||||
.services-ideal {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
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
13
src/app.d.ts
vendored
Normal 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
11
src/app.html
Normal 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>
|
||||
61
src/lib/components/Breadcrumbs.svelte
Normal file
61
src/lib/components/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
items = [],
|
||||
}: {
|
||||
items: BreadcrumbItem[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="section breadcrumbs" aria-label="Breadcrumbs">
|
||||
<div class="container">
|
||||
<ol class="list">
|
||||
{#each items as item, index}
|
||||
<li class="item">
|
||||
{#if item.href}
|
||||
<a href={item.href}>{item.label}</a>
|
||||
{#if index < items.length - 1}<span
|
||||
class="separator"
|
||||
aria-hidden="true">></span
|
||||
>{/if}
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
padding: var(--space-md) 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: var(--font-size-small);
|
||||
margin-inline: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
37
src/lib/components/EngagementsDl.svelte
Normal file
37
src/lib/components/EngagementsDl.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { EngagementItem } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
items,
|
||||
sectionId = 'how-engagements-work',
|
||||
headingId = 'engagements-heading',
|
||||
heading = 'How engagements work',
|
||||
intro,
|
||||
outro,
|
||||
}: {
|
||||
items: EngagementItem[];
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
heading?: string;
|
||||
intro?: string;
|
||||
outro?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id={sectionId} class="section" aria-labelledby={headingId}>
|
||||
<div class="container narrow">
|
||||
<h2 id={headingId}>{heading}</h2>
|
||||
{#if intro}
|
||||
<p>{intro}</p>
|
||||
{/if}
|
||||
<dl class="engagements-list">
|
||||
{#each items as item}
|
||||
<dt>{item.term}</dt>
|
||||
<dd>{item.definition}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if outro}
|
||||
<p>{outro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
85
src/lib/components/FAQ.svelte
Normal file
85
src/lib/components/FAQ.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { FaqList } from '$lib/types/faq';
|
||||
|
||||
const BASE = 'https://mifi.ventures';
|
||||
const PAGE_URL = `${BASE}${page.url?.pathname ?? '/'}`;
|
||||
|
||||
const {
|
||||
faqList,
|
||||
title = 'FAQ',
|
||||
}: {
|
||||
faqList: FaqList;
|
||||
title?: string;
|
||||
} = $props();
|
||||
|
||||
const faqPageJsonLd = $derived(
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': `${PAGE_URL}#faq`,
|
||||
mainEntity: faqList.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const jsonLdHtml = $derived(
|
||||
faqPageJsonLd
|
||||
? '<scr' +
|
||||
'ipt type="application/ld+json">' +
|
||||
faqPageJsonLd +
|
||||
'</scr' +
|
||||
'ipt>'
|
||||
: '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<section id="faq" class="section faq" aria-labelledby="faq-heading">
|
||||
<div class="container narrow">
|
||||
<h2 id="faq-heading">{title}</h2>
|
||||
<dl class="faq-list">
|
||||
{#each faqList as { question, answer }, index (index)}
|
||||
<dt>{question}</dt>
|
||||
<dd>{answer}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{#if jsonLdHtml}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- static trusted JSON-LD -->
|
||||
{@html jsonLdHtml}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.faq {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
margin: 0;
|
||||
|
||||
& dt {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
margin-top: var(--space-xl);
|
||||
margin-bottom: var(--space-sm);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: 0 0 0 var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
max-width: var(--max-text-width);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
src/lib/components/Footer.svelte
Normal file
110
src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<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. All rights reserved.
|
||||
</p>
|
||||
<nav class="footer-links footer-links-wrap" aria-label="Footer links">
|
||||
<a
|
||||
class="link"
|
||||
href="https://linkedin.com/in/the-mifi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn profile (opens in new tab)"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="linkedin"
|
||||
>
|
||||
<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)"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="github"
|
||||
>
|
||||
<GithubIcon size={15} />
|
||||
GitHub
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={15} />
|
||||
</a>
|
||||
<a
|
||||
class="link"
|
||||
href="/privacy-policy"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="privacy-policy"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
class="link"
|
||||
href="/terms-of-service"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="terms-of-service"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</nav>
|
||||
<p class="legal-notice">
|
||||
We improve our products and advertising by using Google Analytics and
|
||||
Microsoft Clarity to see how you use our website. By using our site, you agree
|
||||
that we and Microsoft can collect and use this data. Our <a
|
||||
href="/privacy-policy">privacy policy</a
|
||||
> has more details.
|
||||
</p>
|
||||
</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%;
|
||||
}
|
||||
|
||||
.footer-links-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.legal-notice {
|
||||
margin-top: var(--space-md);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text-tertiary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.legal-notice a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.legal-notice a:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
</style>
|
||||
103
src/lib/components/Hero.svelte
Normal file
103
src/lib/components/Hero.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from '$lib/components/Icon/ExternalLink.svelte';
|
||||
|
||||
interface SecondaryCta {
|
||||
href: string;
|
||||
label: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
bookingLinkTitle,
|
||||
bookingLinkUrl,
|
||||
secondaryCta,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl: string;
|
||||
secondaryCta?: SecondaryCta;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header id="header" class="hero">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
{title}
|
||||
</h1>
|
||||
<p class="subtitle">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href={bookingLinkUrl}
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${bookingLinkTitle} (opens in new tab)`}
|
||||
data-umami-event="book discovery call"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
{bookingLinkTitle}
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
{#if secondaryCta}
|
||||
<a
|
||||
href={secondaryCta.href}
|
||||
class="btn btn-secondary"
|
||||
data-umami-event={secondaryCta.umamiEventLabel}
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</a>
|
||||
{/if}
|
||||
</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);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--space-lg);
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-xxl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.03em;
|
||||
max-width: var(--max-text-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
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) {
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
src/lib/components/Icon/ExternalLink.svelte
Normal file
30
src/lib/components/Icon/ExternalLink.svelte
Normal 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>
|
||||
30
src/lib/components/Icon/FiletypePdf.svelte
Normal file
30
src/lib/components/Icon/FiletypePdf.svelte
Normal 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>
|
||||
30
src/lib/components/Icon/Github.svelte
Normal file
30
src/lib/components/Icon/Github.svelte
Normal 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>
|
||||
30
src/lib/components/Icon/LinkedIn.svelte
Normal file
30
src/lib/components/Icon/LinkedIn.svelte
Normal 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>
|
||||
18
src/lib/components/Logo.svelte
Normal file
18
src/lib/components/Logo.svelte
Normal 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>
|
||||
419
src/lib/components/Navigation.svelte
Normal file
419
src/lib/components/Navigation.svelte
Normal file
@@ -0,0 +1,419 @@
|
||||
<script lang="ts">
|
||||
import { page as pageState } from '$app/state';
|
||||
import Wordmark from './Wordmark.svelte';
|
||||
|
||||
const path = $derived(pageState.url?.pathname ?? '/');
|
||||
|
||||
/** Page slug for body class: "page-home" | "page-services" | "page-services-hands-on-saas-architecture-consultant" etc. Set at build time per route; no client JS. */
|
||||
const bodyClass = $derived(
|
||||
path === '/'
|
||||
? 'page-home'
|
||||
: 'page-' + path.replace(/^\/|\/$/g, '').replace(/\//g, '-'),
|
||||
);
|
||||
|
||||
interface NavigationItem {
|
||||
label: string;
|
||||
href: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
const { items = [], page }: { items: NavigationItem[]; page: string } = $props();
|
||||
</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', { 'page-home': bodyClass === 'page-home' }]}
|
||||
>
|
||||
<a href="/" class="logo-link">
|
||||
<Wordmark />
|
||||
<span class="sr-only">mifi Ventures home page</span>
|
||||
</a>
|
||||
</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',
|
||||
{ 'page-home': bodyClass === 'page-home' },
|
||||
]}
|
||||
>
|
||||
{#if page !== 'home'}
|
||||
<a href="/" class="logo-link">
|
||||
<Wordmark />
|
||||
<span class="sr-only">mifi Ventures home page</span>
|
||||
</a>
|
||||
{:else}
|
||||
<Wordmark />
|
||||
{/if}
|
||||
</span>
|
||||
<ul class="nav-list">
|
||||
{#each items as item (item.href)}
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label={item.umamiEventLabel}
|
||||
data-umami-event-page={page}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="nav-item nav-back-to-top">
|
||||
<a
|
||||
href="#header"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="back to top">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;
|
||||
}
|
||||
}
|
||||
|
||||
& .logo-link {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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: space-between;
|
||||
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.page-home {
|
||||
/* Fallback when scroll-driven animations aren’t 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.page-home {
|
||||
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>
|
||||
97
src/lib/components/ScheduleSection.svelte
Normal file
97
src/lib/components/ScheduleSection.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
bookingLinkTitle,
|
||||
bookingLinkUrl = 'https://cal.mifi.ventures/the-mifi/30min',
|
||||
showEmailLink = false,
|
||||
showServicesLink = false,
|
||||
sectionId = 'contact',
|
||||
headingId = 'contact-heading',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl?: string;
|
||||
showEmailLink?: boolean;
|
||||
showServicesLink?: boolean;
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id={sectionId} class="section schedule-section" aria-labelledby={headingId}>
|
||||
<div class="container">
|
||||
<h2 id={headingId} class="section-title">{title}</h2>
|
||||
<p class="schedule-text">{subtitle}</p>
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href={bookingLinkUrl}
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
{bookingLinkTitle}
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
{#if showEmailLink}
|
||||
<a
|
||||
href="mailto:hello@mifi.ventures"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="email"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
Email me
|
||||
</a>
|
||||
{/if}
|
||||
{#if showServicesLink}
|
||||
<a
|
||||
href="/services"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="view services"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
View services
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</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: var(--max-text-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/lib/components/ServiceSection.svelte
Normal file
82
src/lib/components/ServiceSection.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { ServiceSectionContent } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
section,
|
||||
}: {
|
||||
section: ServiceSectionContent;
|
||||
} = $props();
|
||||
|
||||
const headingId = $derived(section.headingId ?? `${section.id}-heading`);
|
||||
const sectionClasses = $derived(
|
||||
['section', section.sectionClass].filter(Boolean).join(' '),
|
||||
);
|
||||
const containerClass = $derived(
|
||||
section.narrowContainer === false ? 'container' : 'container narrow',
|
||||
);
|
||||
const listClass = $derived(
|
||||
[section.bulletsListClass, 'content-list'].filter(Boolean).join(' '),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section id={section.id} class={sectionClasses} aria-labelledby={headingId}>
|
||||
<div class={containerClass}>
|
||||
<h2 id={headingId} class={{ 'sr-only': section.headingSrOnly ?? false }}>
|
||||
{section.heading}
|
||||
</h2>
|
||||
|
||||
{#if section.lede}
|
||||
<p>{section.lede}</p>
|
||||
{/if}
|
||||
|
||||
{#each section.paragraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
|
||||
{#each section.subsections ?? [] as sub, subIndex}
|
||||
{@const subId = sub.headingId ?? `${section.id}-sub-${subIndex}`}
|
||||
<h3 id={subId}>{sub.heading}</h3>
|
||||
{#each sub.paragraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
{#if (sub.bullets?.length ?? 0) > 0}
|
||||
<ul class="content-list">
|
||||
{#each sub.bullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if (section.bullets?.length ?? 0) > 0}
|
||||
<ul class={listClass}>
|
||||
{#each section.bullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if (section.orderedBullets?.length ?? 0) > 0}
|
||||
<ol class="content-list ordered">
|
||||
{#each section.orderedBullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#each section.trailingParagraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
|
||||
{#if (section.footerLinks?.length ?? 0) > 0}
|
||||
<p>
|
||||
{#each section.footerLinks as link, i}
|
||||
{#if i > 0}
|
||||
<span aria-hidden="true"> · </span>
|
||||
{/if}
|
||||
<a href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
115
src/lib/components/ServicesCardGrid.svelte
Normal file
115
src/lib/components/ServicesCardGrid.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { ServiceCard } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
services,
|
||||
sectionId = 'services-grid',
|
||||
headingId = 'services-heading',
|
||||
heading = 'Services',
|
||||
overview = '',
|
||||
surface = 'bg',
|
||||
}: {
|
||||
services: ServiceCard[];
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
heading?: string;
|
||||
overview?: string;
|
||||
surface?: 'bg' | 'bg-alt';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
id={sectionId}
|
||||
class={['section services-grid-section', { 'bg-alt': surface === 'bg-alt' }]}
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div class="container">
|
||||
<h2 id={headingId} class="section-title">{heading}</h2>
|
||||
{#if overview}
|
||||
<p class="overview">{overview}</p>
|
||||
{/if}
|
||||
<ul class="services-card-list">
|
||||
{#each services as service (service.href)}
|
||||
<li class={['services-card', { bg: surface === 'bg-alt' }]}>
|
||||
<h3 class="title">{service.title}</h3>
|
||||
<p class="desc">{service.description}</p>
|
||||
<a
|
||||
href={service.href}
|
||||
class="link"
|
||||
data-umami-event="service link"
|
||||
data-umami-event-label={service.href}
|
||||
>
|
||||
Learn more
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.services-grid-section {
|
||||
background-color: var(--color-bg);
|
||||
|
||||
&.bg-alt {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
}
|
||||
|
||||
.overview {
|
||||
max-width: var(--max-text-width);
|
||||
margin: 0 auto var(--space-xxl) auto;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.services-card-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.services-card {
|
||||
padding: var(--space-xl);
|
||||
background-color: var(--color-bg-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.bg {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1;
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
& span {
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/lib/components/TOC.svelte
Normal file
58
src/lib/components/TOC.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface TOCItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const {
|
||||
ariaLabel = 'Page contents',
|
||||
items,
|
||||
title = 'On this page',
|
||||
}: {
|
||||
ariaLabel?: string;
|
||||
items: TOCItem[];
|
||||
title?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="section toc" aria-label={ariaLabel}>
|
||||
<div class="container">
|
||||
<h2 class="title">{title}</h2>
|
||||
<ul class="list">
|
||||
{#each items as item}
|
||||
<li><a href={item.href}>{item.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.toc {
|
||||
padding: var(--space-xl) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm) var(--space-xl);
|
||||
|
||||
& a {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-medium);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/lib/components/WhoGrid.svelte
Normal file
59
src/lib/components/WhoGrid.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
const {
|
||||
showTitle = false,
|
||||
title = 'Who this is for',
|
||||
whoForList,
|
||||
whoNotList,
|
||||
whoForHeading = 'Good fit',
|
||||
whoNotHeading = 'Who this is not for',
|
||||
}: {
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
whoForList: string[];
|
||||
whoNotList: string[];
|
||||
whoForHeading?: string;
|
||||
whoNotHeading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id="who-its-for" class="section" aria-labelledby="who-for-heading">
|
||||
<div class="container">
|
||||
<h2 id="who-for-heading" class={['title', { 'sr-only': !showTitle }]}>{title}</h2>
|
||||
<div class="who-grid">
|
||||
<div class="who-block">
|
||||
<h3 id="who-for-list-heading" class="list-heading">{whoForHeading}</h3>
|
||||
<ul class="content-list" aria-labelledby="who-for-list-heading">
|
||||
{#each whoForList as item, index (index)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="who-block">
|
||||
<h3 id="who-not-list-heading" class="list-heading">{whoNotHeading}</h3>
|
||||
<ul class="content-list" aria-labelledby="who-not-list-heading">
|
||||
{#each whoNotList as item, index (index)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.who-grid {
|
||||
display: grid;
|
||||
gap: var(--space-xxl);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.list-heading {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
61
src/lib/components/Wordmark.svelte
Normal file
61
src/lib/components/Wordmark.svelte
Normal 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
|
||||
>
|
||||
65
src/lib/components/home/EngagementsSection.svelte
Normal file
65
src/lib/components/home/EngagementsSection.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { engagements } from '$lib/data/home/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>
|
||||
274
src/lib/components/home/ExperienceSection.svelte
Normal file
274
src/lib/components/home/ExperienceSection.svelte
Normal file
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { experienceLogos, experienceTextList } from '$lib/data/home/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>
|
||||
101
src/lib/components/home/Hero.svelte
Normal file
101
src/lib/components/home/Hero.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from '$lib/components/Icon/ExternalLink.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
</script>
|
||||
|
||||
<header id="header" class="hero">
|
||||
<div class="container">
|
||||
<Logo />
|
||||
<h2 class="headline">Hands-On Product Architecture for Early-Stage SaaS</h2>
|
||||
<p class="subhead">
|
||||
I help SaaS teams ship quickly without creating frontend debt, architectural
|
||||
drift, or technical complexity that slows iteration later.
|
||||
</p>
|
||||
<p class="supporting">
|
||||
Mike Fitzpatrick — product architect and senior software engineer
|
||||
</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_content=hero"
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
Schedule a 30-minute intro call
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
<a
|
||||
href="/services"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="view services"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
View services
|
||||
</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-xxl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-heading);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.subhead {
|
||||
max-width: var(--max-narrow-width);
|
||||
margin: 0 auto var(--space-md) auto;
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.supporting {
|
||||
margin: 0 auto var(--space-xl) auto;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.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>
|
||||
14
src/lib/components/home/HowWeWork.svelte
Normal file
14
src/lib/components/home/HowWeWork.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { howIWorkItems } from '$lib/data/home/content';
|
||||
</script>
|
||||
|
||||
<section id="process" class="section" aria-labelledby="process-heading">
|
||||
<div class="container">
|
||||
<h2 id="process-heading" class="section-title">How I Work</h2>
|
||||
<ul class="content-list">
|
||||
{#each howIWorkItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
14
src/lib/components/home/ImpactSection.svelte
Normal file
14
src/lib/components/home/ImpactSection.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { impactItems } from '$lib/data/home/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>
|
||||
25
src/lib/components/home/WhatWeDo.svelte
Normal file
25
src/lib/components/home/WhatWeDo.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
whatIHelpTeamsFixIntro,
|
||||
whatIHelpTeamsFixItems,
|
||||
} from '$lib/data/home/content';
|
||||
</script>
|
||||
|
||||
<section id="what-i-help-fix" class="section" aria-labelledby="what-i-help-fix-heading">
|
||||
<div class="container">
|
||||
<h2 id="what-i-help-fix-heading" class="section-title">What I Help Teams Fix</h2>
|
||||
<p class="what-fix-intro">{whatIHelpTeamsFixIntro}</p>
|
||||
<ul class="content-list">
|
||||
{#each whatIHelpTeamsFixItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.what-fix-intro {
|
||||
max-width: var(--max-text-width);
|
||||
margin: 0 auto var(--space-lg) auto;
|
||||
}
|
||||
</style>
|
||||
8
src/lib/copyright-year.test.ts
Normal file
8
src/lib/copyright-year.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
7
src/lib/copyright-year.ts
Normal file
7
src/lib/copyright-year.ts
Normal 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();
|
||||
}
|
||||
27
src/lib/data/home/content.ts
Normal file
27
src/lib/data/home/content.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const whatIHelpTeamsFixIntro =
|
||||
'Most early-stage SaaS products do not break because of lack of effort. They break because the foundation was rushed, the frontend was treated as secondary, or complexity arrived before the company was ready for it.';
|
||||
|
||||
export const whatIHelpTeamsFixItems = [
|
||||
"Backend-heavy products with frontend foundations that don't scale",
|
||||
'CSS and component systems that slow iteration',
|
||||
'MVPs built quickly but not structured to evolve',
|
||||
'Teams shipping features without architectural guardrails',
|
||||
'Infrastructure and tooling decisions that outpace company stage',
|
||||
];
|
||||
|
||||
export const impactItems = [
|
||||
'Faster iteration through cleaner frontend systems and reusable components.',
|
||||
'Improved performance, Core Web Vitals, and technical SEO on critical user journeys.',
|
||||
'Accessibility built into core UI systems—semantic markup, keyboard parity, and screen reader support in reusable components.',
|
||||
'Order in complex or aging codebases: simpler structure, less duplication, clearer ownership so teams ship confidently without over-engineering.',
|
||||
'Shared component libraries and UI systems that improve consistency, velocity, and maintainability.',
|
||||
'Stronger technical direction: partnering with product and engineering leadership to translate goals into shippable systems.',
|
||||
];
|
||||
|
||||
export const howIWorkItems = [
|
||||
'Hands-on and implementation-led, not diagram theater.',
|
||||
'Scoped for real progress, not endless strategy loops.',
|
||||
'Built around clarity, accessibility, and maintainability.',
|
||||
'Flexible engagement models: focused projects, implementation, advisory.',
|
||||
'Documentation and handoff that internal teams can actually use.',
|
||||
];
|
||||
27
src/lib/data/home/engagements.ts
Normal file
27
src/lib/data/home/engagements.ts
Normal 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.",
|
||||
},
|
||||
];
|
||||
53
src/lib/data/home/experience.ts
Normal file
53
src/lib/data/home/experience.ts
Normal 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);
|
||||
11
src/lib/data/home/home-meta.ts
Normal file
11
src/lib/data/home/home-meta.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageMeta } from '$lib/seo';
|
||||
import { baseJsonLdGraph, catalogJsonLdGraph } from '$lib/data/json-ld/baseJsonLdGraphs';
|
||||
import { homepageJsonLdGraph } from '$lib/data/json-ld/webpageJsonLdGraphs';
|
||||
|
||||
export const homeMeta: PageMeta = {
|
||||
title: 'mifi Ventures — Hands-On Product Architecture for Early-Stage SaaS | Boston, MA',
|
||||
description:
|
||||
'Product architecture and senior software engineering for early-stage SaaS teams. Mike Fitzpatrick works inside your codebase to build foundations that ship fast without structural debt—frontend systems, MVP launch, fractional CTO, and stage-aligned infrastructure.',
|
||||
canonical: 'https://mifi.ventures',
|
||||
jsonLd: [...baseJsonLdGraph, homepageJsonLdGraph, ...catalogJsonLdGraph],
|
||||
};
|
||||
22
src/lib/data/home/navigation.ts
Normal file
22
src/lib/data/home/navigation.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const items = [
|
||||
{
|
||||
label: 'Services',
|
||||
href: '/services',
|
||||
umamiEventLabel: 'services',
|
||||
},
|
||||
{
|
||||
label: 'Impact',
|
||||
href: '/#impact',
|
||||
umamiEventLabel: 'impact',
|
||||
},
|
||||
{
|
||||
label: 'Process',
|
||||
href: '/#process',
|
||||
umamiEventLabel: 'process',
|
||||
},
|
||||
{
|
||||
label: 'Contact',
|
||||
href: '/#contact',
|
||||
umamiEventLabel: 'contact',
|
||||
},
|
||||
];
|
||||
26
src/lib/data/home/services.ts
Normal file
26
src/lib/data/home/services.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const services = [
|
||||
{
|
||||
title: 'Hands-On SaaS Architecture',
|
||||
description:
|
||||
'Build the foundations that let SaaS products evolve without accumulating structural debt.',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
title: 'MVP Architecture & Launch',
|
||||
description:
|
||||
'Ship quickly without creating a frontend mess or fragile product foundation.',
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
title: 'Fractional CTO / Technical Partner',
|
||||
description:
|
||||
'Technical leadership for teams that need architectural direction without hiring a full-time CTO.',
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
{
|
||||
title: 'Stage-Aligned Infrastructure',
|
||||
description:
|
||||
"Infrastructure decisions that match your company's stage, without unnecessary SaaS sprawl or cloud complexity.",
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
];
|
||||
190
src/lib/data/json-ld/baseJsonLdGraphs.ts
Normal file
190
src/lib/data/json-ld/baseJsonLdGraphs.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { BASE, PHONE, WORDMARK } from './constants';
|
||||
|
||||
/**
|
||||
* Default JSON-LD graph nodes (Organization, Person, WebSite, WebPage,
|
||||
* ProfessionalService, OfferCatalog).
|
||||
*
|
||||
* Used for the home page; other pages can add or override via meta.jsonLd.
|
||||
*/
|
||||
|
||||
export const baseJsonLdGraph: Record<string, unknown>[] = [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': `${BASE}/#organization`,
|
||||
name: 'mifi Ventures',
|
||||
legalName: 'mifi Ventures LLC',
|
||||
url: `${BASE}/`,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
telephone: PHONE,
|
||||
description:
|
||||
'Hands-on product architecture for early-stage SaaS teams: SaaS product architecture, MVP architecture and launch support, fractional CTO guidance, and stage-aligned infrastructure strategy.',
|
||||
founder: { '@id': `${BASE}/#principal` },
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Boston',
|
||||
addressRegion: 'MA',
|
||||
addressCountry: 'US',
|
||||
},
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
hasOfferCatalog: { '@id': `${BASE}/#services` },
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/mifi-ventures',
|
||||
'https://github.com/mifi-ventures',
|
||||
],
|
||||
contactPoint: [
|
||||
{
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'sales',
|
||||
telephone: PHONE,
|
||||
areaServed: 'US',
|
||||
availableLanguage: ['en'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'Person',
|
||||
'@id': `${BASE}/#principal`,
|
||||
name: 'Mike Fitzpatrick',
|
||||
jobTitle: 'Product Architect and Senior Software Engineer',
|
||||
description:
|
||||
'Hands-on technical partner for early-stage SaaS teams; works inside codebases to build strong product foundations that ship fast without structural debt.',
|
||||
url: `${BASE}/`,
|
||||
worksFor: { '@id': `${BASE}/#organization` },
|
||||
knowsAbout: [
|
||||
'Product Architecture',
|
||||
'Frontend Architecture',
|
||||
'UI Architecture',
|
||||
'React Development',
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Component Libraries',
|
||||
'Design Systems',
|
||||
'Design Tokens',
|
||||
'CSS Architecture',
|
||||
'Web Accessibility (WCAG)',
|
||||
'Technical SEO',
|
||||
'Core Web Vitals',
|
||||
'MVP Architecture',
|
||||
'Greenfield Product Development',
|
||||
'Fractional CTO',
|
||||
'Startup Infrastructure Strategy',
|
||||
'Code Refactoring',
|
||||
],
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/the-mifi',
|
||||
'https://github.com/the-mifi',
|
||||
'https://mifi.dev',
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': `${BASE}/#website`,
|
||||
url: `${BASE}/`,
|
||||
name: 'mifi Ventures',
|
||||
description: 'Hands-on product architecture for early-stage SaaS teams.',
|
||||
publisher: { '@id': `${BASE}/#organization` },
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'ReserveAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://cal.mifi.ventures/the-mifi',
|
||||
},
|
||||
name: 'Schedule a 30-minute intro call',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const catalogJsonLdGraph: Record<string, unknown>[] = [
|
||||
{
|
||||
'@type': 'ProfessionalService',
|
||||
'@id': `${BASE}/#professional-service`,
|
||||
name: 'mifi Ventures',
|
||||
url: `${BASE}/`,
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
telephone: PHONE,
|
||||
description:
|
||||
'Hands-on product architecture and senior software engineering for early-stage SaaS teams: SaaS product architecture, MVP architecture and launch, fractional CTO guidance, and stage-aligned infrastructure.',
|
||||
serviceType: [
|
||||
'SaaS Product Architecture',
|
||||
'MVP Architecture and Launch Consulting',
|
||||
'Fractional CTO Services',
|
||||
'Startup Infrastructure Strategy',
|
||||
],
|
||||
provider: { '@id': `${BASE}/#organization` },
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Boston',
|
||||
addressRegion: 'MA',
|
||||
addressCountry: 'US',
|
||||
},
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
},
|
||||
{
|
||||
'@type': 'OfferCatalog',
|
||||
'@id': `${BASE}/#services`,
|
||||
name: 'SaaS Architecture Services',
|
||||
description:
|
||||
'Consulting services offered by mifi Ventures for early-stage SaaS teams.',
|
||||
numberOfItems: 4,
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: `${BASE}/hands-on-saas-architecture-consultant`,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: 'Hands-On SaaS Architecture',
|
||||
description:
|
||||
'Hands-on product architecture for early-stage SaaS teams, focused on frontend systems, reusable components, design tokens, accessibility, and scalable foundations for fast iteration.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: `${BASE}/mvp-architecture-and-launch`,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: 'MVP Architecture & Launch',
|
||||
description:
|
||||
'Architecture and implementation support for early-stage SaaS MVPs that need to ship quickly without creating structural debt, fragile CSS, or difficult-to-extend product foundations.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: `${BASE}/fractional-cto-for-early-stage-saas`,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: 'Fractional CTO / Technical Partner',
|
||||
description:
|
||||
'Part-time technical leadership for early-stage SaaS teams that need architectural guidance, tradeoff evaluation, and hands-on technical oversight without hiring a full-time CTO.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: `${BASE}/stage-aligned-infrastructure`,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: 'Stage-Aligned Infrastructure',
|
||||
description:
|
||||
'Infrastructure strategy for early-stage SaaS teams that aligns cloud, SaaS, and deployment decisions with company stage, reducing unnecessary complexity and preserving runway.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
3
src/lib/data/json-ld/constants.ts
Normal file
3
src/lib/data/json-ld/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const BASE = 'https://mifi.ventures';
|
||||
export const WORDMARK = `${BASE}/assets/wordmark.svg`;
|
||||
export const PHONE = '+1-888-991-6434';
|
||||
68
src/lib/data/json-ld/servicesJsonLdGraphs.ts
Normal file
68
src/lib/data/json-ld/servicesJsonLdGraphs.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { BASE } from './constants';
|
||||
|
||||
export const handsOnSaaSArchitectureConsultantServiceJsonLdGraph: Record<
|
||||
string,
|
||||
unknown
|
||||
> = {
|
||||
'@type': 'Service',
|
||||
'@id': `${BASE}/hands-on-saas-architecture-consultant#service`,
|
||||
name: 'Hands-On SaaS Architecture',
|
||||
url: `${BASE}/hands-on-saas-architecture-consultant`,
|
||||
description:
|
||||
'Hands-on product architecture for early-stage SaaS teams, focused on frontend systems, reusable components, design tokens, accessibility, and scalable foundations for fast iteration.',
|
||||
serviceType: 'SaaS Product Architecture',
|
||||
provider: { '@id': `${BASE}/#organization` },
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
audience: {
|
||||
'@type': 'Audience',
|
||||
audienceType: 'Early-stage SaaS founders and engineering teams',
|
||||
},
|
||||
};
|
||||
|
||||
export const mvpArchitectureAndLaunchServiceJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'Service',
|
||||
'@id': `${BASE}/mvp-architecture-and-launch#service`,
|
||||
name: 'MVP Architecture & Launch',
|
||||
url: `${BASE}/mvp-architecture-and-launch`,
|
||||
description:
|
||||
'Architecture and implementation support for early-stage SaaS MVPs that need to ship quickly without structural debt, fragile CSS, or difficult-to-extend product foundations.',
|
||||
serviceType: 'MVP Architecture and Launch Consulting',
|
||||
provider: { '@id': `${BASE}/#organization` },
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
audience: {
|
||||
'@type': 'Audience',
|
||||
audienceType: 'Founder-led SaaS startups and early product teams',
|
||||
},
|
||||
};
|
||||
|
||||
export const fractionalCtoForEarlyStageSaaSServiceJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'Service',
|
||||
'@id': `${BASE}/fractional-cto-for-early-stage-saas#service`,
|
||||
name: 'Fractional CTO / Technical Partner',
|
||||
url: `${BASE}/fractional-cto-for-early-stage-saas`,
|
||||
description:
|
||||
'Part-time technical leadership for early-stage SaaS teams that need architectural guidance, tradeoff evaluation, and hands-on technical oversight without hiring a full-time CTO.',
|
||||
serviceType: 'Fractional CTO Services',
|
||||
provider: { '@id': `${BASE}/#organization` },
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
audience: {
|
||||
'@type': 'Audience',
|
||||
audienceType: 'Early-stage SaaS founders and growing engineering teams',
|
||||
},
|
||||
};
|
||||
|
||||
export const stageAlignedInfrastructureServiceJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'Service',
|
||||
'@id': `${BASE}/stage-aligned-infrastructure#service`,
|
||||
name: 'Stage-Aligned Infrastructure',
|
||||
url: `${BASE}/stage-aligned-infrastructure`,
|
||||
description:
|
||||
'Infrastructure strategy for early-stage SaaS teams that aligns cloud, SaaS, and deployment decisions with company stage, reducing unnecessary complexity and preserving runway.',
|
||||
serviceType: 'Startup Infrastructure Strategy',
|
||||
provider: { '@id': `${BASE}/#organization` },
|
||||
areaServed: { '@type': 'Country', name: 'United States' },
|
||||
audience: {
|
||||
'@type': 'Audience',
|
||||
audienceType: 'Early-stage SaaS founders and small engineering teams',
|
||||
},
|
||||
};
|
||||
130
src/lib/data/json-ld/webpageJsonLdGraphs.ts
Normal file
130
src/lib/data/json-ld/webpageJsonLdGraphs.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { BASE, WORDMARK } from './constants';
|
||||
|
||||
export const homepageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/#webpage`,
|
||||
url: `${BASE}/`,
|
||||
name: 'mifi Ventures | Hands-On Product Architecture for Early-Stage SaaS',
|
||||
description:
|
||||
'Hands-on product architecture for early-stage SaaS teams—SaaS architecture, MVP launch support, fractional CTO guidance, and stage-aligned infrastructure strategy.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
mainEntity: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const servicesPageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/services#webpage`,
|
||||
url: `${BASE}/services`,
|
||||
name: 'SaaS Architecture Services | mifi Ventures',
|
||||
description:
|
||||
'Hands-on SaaS architecture consulting for early-stage teams. Services include product architecture, MVP launch support, fractional CTO guidance, and stage-aligned infrastructure strategy.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const handsOnSaaSArchitectureConsultantPageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/hands-on-saas-architecture-consultant#webpage`,
|
||||
url: `${BASE}/hands-on-saas-architecture-consultant`,
|
||||
name: 'SaaS Product Architecture Consultant | mifi Ventures',
|
||||
description:
|
||||
'Hands-on product architecture for early-stage SaaS teams. Build frontend foundations that scale with reusable components, design tokens, accessibility, and clean architecture.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const mvpArchitectureAndLaunchPageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/mvp-architecture-and-launch#webpage`,
|
||||
url: `${BASE}/mvp-architecture-and-launch`,
|
||||
name: 'MVP Architecture & Launch Consultant | mifi Ventures',
|
||||
description:
|
||||
'Architecture and implementation support for early-stage SaaS MVPs that need to ship quickly without structural debt, fragile CSS, or difficult-to-extend product foundations.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const fractionalCtoForEarlyStageSaaSPageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/fractional-cto-for-early-stage-saas#webpage`,
|
||||
url: `${BASE}/fractional-cto-for-early-stage-saas`,
|
||||
name: 'Fractional CTO for Early-Stage SaaS | mifi Ventures',
|
||||
description:
|
||||
'Hands-on technical leadership for early-stage SaaS teams that need architectural guidance, technical tradeoff evaluation, and senior oversight without hiring a full-time CTO.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const stageAlignedInfrastructurePageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/stage-aligned-infrastructure#webpage`,
|
||||
url: `${BASE}/stage-aligned-infrastructure`,
|
||||
name: 'Stage-Aligned Infrastructure for Startups | mifi Ventures',
|
||||
description:
|
||||
'Infrastructure strategy for early-stage SaaS teams that aligns cloud, SaaS, and deployment decisions with company stage, reducing unnecessary complexity and preserving runway.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const privacyPolicyPageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/privacy-policy#webpage`,
|
||||
url: `${BASE}/privacy-policy`,
|
||||
name: 'Privacy Policy | mifi Ventures',
|
||||
description:
|
||||
'Read the mifi Ventures privacy policy, including how personal information is collected, used, stored, and protected.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
export const termsOfServicePageJsonLdGraph: Record<string, unknown> = {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${BASE}/terms-of-service#webpage`,
|
||||
url: `${BASE}/terms-of-service`,
|
||||
name: 'Terms of Service | mifi Ventures',
|
||||
description:
|
||||
'Read the mifi Ventures terms of service, including the terms governing use of the site and consulting services.',
|
||||
isPartOf: { '@id': `${BASE}/#website` },
|
||||
about: { '@id': `${BASE}/#organization` },
|
||||
primaryImageOfPage: {
|
||||
'@type': 'ImageObject',
|
||||
url: WORDMARK,
|
||||
},
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
229
src/lib/data/privacy-policy.ts
Normal file
229
src/lib/data/privacy-policy.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Privacy Policy content for mifi Ventures. Used by /privacy-policy.
|
||||
* Last updated: March 12, 2026. Includes Messaging Policy (SMS) for OpenPhone / A2P compliance; Microsoft Clarity and Google Analytics.
|
||||
*/
|
||||
|
||||
export interface LegalSectionLink {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LegalSection {
|
||||
id: string;
|
||||
heading: string;
|
||||
body: string[];
|
||||
list?: string[];
|
||||
/** Optional links to inject into body paragraphs (URLs in text become <a> with this label) */
|
||||
links?: LegalSectionLink[];
|
||||
/** Numbered sub-sections (e.g. Messaging Policy 1–6) */
|
||||
subsections?: { title: string; body: string[]; list?: string[] }[];
|
||||
}
|
||||
|
||||
export const privacyPolicy = {
|
||||
title: 'Privacy Policy',
|
||||
lastUpdated: 'March 12, 2026',
|
||||
intro: [
|
||||
'mifi Ventures LLC respects your privacy and is committed to protecting personal information shared through this website and related communications.',
|
||||
'This policy explains what information we collect, how it is used, and how it is protected.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'information-we-collect',
|
||||
heading: 'Information We Collect',
|
||||
body: [
|
||||
'We may collect limited personal information when you interact with the website or contact us.',
|
||||
'This may include:',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-information',
|
||||
heading: 'Contact Information',
|
||||
body: [],
|
||||
list: ['Name', 'Email address', 'Phone number'],
|
||||
},
|
||||
{
|
||||
id: 'communication-data',
|
||||
heading: 'Communication Data',
|
||||
body: [
|
||||
'Information you provide when contacting us through email, contact forms, or SMS.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'technical-information',
|
||||
heading: 'Technical Information',
|
||||
body: [
|
||||
'Basic technical information collected through analytics or server logs, such as:',
|
||||
],
|
||||
list: ['IP address', 'Browser type', 'Pages visited', 'Device type'],
|
||||
},
|
||||
{
|
||||
id: 'technical-information-use',
|
||||
heading: 'Use of technical information',
|
||||
body: [
|
||||
'This information is used only to maintain the website, improve performance, and monitor security.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analytics-and-tracking',
|
||||
heading: 'Analytics and Tracking Technologies',
|
||||
body: [
|
||||
'We use a mix of first-party and third-party analytics tools to understand how visitors use this website and to improve our services.',
|
||||
'We use Umami as a first-party analytics tool to measure aggregate usage of this site for internal reporting and performance insights; Umami data is not sold or shared for third-party advertising.',
|
||||
'We partner with Microsoft Clarity and Microsoft Advertising to capture how you use and interact with our website through behavioral metrics, heatmaps, and session replay to improve and market our products and services. Website usage data is captured using first- and third-party cookies and similar tracking technologies to determine the popularity of content and online activity. We also use this information for site optimization, security and fraud detection, and advertising. These third-party tools only run if you consent to non-essential analytics via our cookie banner.',
|
||||
'For more information about how Microsoft collects and uses your data, see the Microsoft Privacy Statement: https://www.microsoft.com/privacy/privacystatement.',
|
||||
'We also use Google Analytics (only if you consent) to collect information about website usage, such as pages visited, time on site, and browser and device information. Google Analytics uses cookies and similar technologies to help us analyze how visitors use the site and to compile aggregated statistics.',
|
||||
'You can learn more about how Google handles data in Google Analytics at: https://policies.google.com/privacy and https://policies.google.com/technologies/partner-sites.',
|
||||
'We store your analytics preference (for example, whether you accepted or rejected non-essential analytics) in a small piece of first-party device storage so we can remember your choice on future visits.',
|
||||
],
|
||||
links: [
|
||||
{
|
||||
href: 'https://www.microsoft.com/privacy/privacystatement',
|
||||
label: 'Microsoft Privacy Statement',
|
||||
},
|
||||
{
|
||||
href: 'https://policies.google.com/privacy',
|
||||
label: 'Google Privacy Policy',
|
||||
},
|
||||
{
|
||||
href: 'https://policies.google.com/technologies/partner-sites',
|
||||
label: 'How Google uses data from sites and apps',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'how-we-use',
|
||||
heading: 'How We Use Information',
|
||||
body: ['Information collected may be used to:'],
|
||||
list: [
|
||||
'Respond to inquiries',
|
||||
'Provide consulting or development services',
|
||||
'Communicate with clients',
|
||||
'Improve website functionality',
|
||||
'Maintain security and prevent abuse',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'messaging-policy',
|
||||
heading: 'Messaging Policy (SMS)',
|
||||
body: [
|
||||
'mifi Ventures uses SMS messages only for direct, conversational communication with clients or prospective clients.',
|
||||
],
|
||||
subsections: [
|
||||
{
|
||||
title: 'Purpose',
|
||||
body: ['SMS may be used for:'],
|
||||
list: [
|
||||
'Responding to inbound inquiries',
|
||||
'Scheduling and appointment coordination',
|
||||
'Project and service-related communication',
|
||||
'Status updates and time-sensitive notifications requested by the client',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'No Marketing / No Sale of Data',
|
||||
body: [
|
||||
'We do not send unsolicited marketing text messages. We do not sell, rent, or share phone numbers for marketing purposes.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Consent',
|
||||
body: [
|
||||
'You provide consent by initiating contact with us, requesting communication by text, and/or providing your phone number for communication related to your inquiry or services.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Opt-Out',
|
||||
body: [
|
||||
'You may opt out at any time by replying STOP. After opting out, you will no longer receive SMS messages unless you re-initiate contact.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Message Frequency and Rates',
|
||||
body: [
|
||||
'Message frequency varies depending on the nature of the communication. Standard message and data rates may apply depending on your mobile carrier.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
body: ['For support, contact: contact@mifi.ventures'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sms-phone-communications',
|
||||
heading: 'SMS and Phone Communications',
|
||||
body: [
|
||||
'If you provide a phone number when contacting us, we may respond via phone call or SMS.',
|
||||
'SMS messages are used only for direct communication with clients or prospective clients, including scheduling, service communication, or responses to inquiries.',
|
||||
'We do not send unsolicited marketing messages.',
|
||||
'You may opt out of SMS communication at any time by replying STOP.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-security',
|
||||
heading: 'Data Security',
|
||||
body: [
|
||||
'mifi Ventures implements reasonable administrative and technical safeguards to protect personal information.',
|
||||
'Security measures may include:',
|
||||
],
|
||||
list: [
|
||||
'Encrypted website connections (HTTPS)',
|
||||
'Secure hosting infrastructure',
|
||||
'Restricted access to internal systems',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-security-note',
|
||||
heading: 'Security limitations',
|
||||
body: [
|
||||
'While we take reasonable precautions, no online system can guarantee absolute security.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-sharing',
|
||||
heading: 'Data Sharing',
|
||||
body: [
|
||||
'mifi Ventures does not sell, rent, or trade personal information.',
|
||||
'Information may be shared only when necessary with trusted service providers that support the operation of the website or business services. These providers are required to protect the confidentiality of the data they process.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-retention',
|
||||
heading: 'Data Retention',
|
||||
body: [
|
||||
'Personal information is retained only for as long as necessary to fulfill communication, operational, or legal obligations.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'your-rights',
|
||||
heading: 'Your Rights',
|
||||
body: [
|
||||
'Depending on your jurisdiction, you may have the right to: request access to personal data; request correction or deletion of data; withdraw consent for communications.',
|
||||
'If you are located in a region with specific data protection laws (such as the European Economic Area or the United Kingdom), you may have additional rights under those laws; we will handle such requests in line with applicable legal requirements.',
|
||||
'Requests may be submitted using the contact information below.',
|
||||
],
|
||||
list: [
|
||||
'Request access to personal data',
|
||||
'Request correction or deletion of data',
|
||||
'Withdraw consent for communications',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'policy-updates',
|
||||
heading: 'Policy Updates',
|
||||
body: [
|
||||
'This Privacy Policy may be updated periodically to reflect operational or legal changes. The updated date will appear at the top of the page.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
heading: 'Contact',
|
||||
body: [
|
||||
'Questions regarding this Privacy Policy may be directed to:',
|
||||
'mifi Ventures LLC',
|
||||
'legal@mifi.ventures',
|
||||
'https://mifi.ventures',
|
||||
],
|
||||
},
|
||||
] as LegalSection[],
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ServiceDetailContent } from '$lib/types/service-page';
|
||||
|
||||
const discoveryCallUrl =
|
||||
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=fractional_cto_page';
|
||||
|
||||
export const pageContent: ServiceDetailContent = {
|
||||
hero: {
|
||||
title: 'Fractional CTO for Early-Stage SaaS',
|
||||
subtitle:
|
||||
'At a certain point, early-stage SaaS teams need more than just developers shipping features. They need someone steering the technical decisions. I work alongside founders and engineers to keep architecture sane, prevent premature complexity, and help teams move fast without creating structural problems.',
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: discoveryCallUrl,
|
||||
secondaryCta: {
|
||||
href: '#approach',
|
||||
label: 'See how I work',
|
||||
umamiEventLabel: 'see how i work',
|
||||
},
|
||||
},
|
||||
tocItems: [
|
||||
{ label: 'When teams need technical leadership', href: '#when-teams' },
|
||||
{ label: 'The two common failure modes', href: '#failure-modes' },
|
||||
{ label: 'What working with me looks like', href: '#approach' },
|
||||
{ label: 'What engineering teams notice', href: '#what-changes' },
|
||||
{ label: 'Engagement structure', href: '#engagement' },
|
||||
{ label: "Who it's for", href: '#who-its-for' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
{ label: 'Get in touch', href: '#final-cta' },
|
||||
],
|
||||
navItems: [
|
||||
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
|
||||
{ label: 'How I work', href: '#approach', umamiEventLabel: 'approach' },
|
||||
{ label: 'Engagement', href: '#engagement', umamiEventLabel: 'engagement' },
|
||||
{ label: 'FAQ', href: '#faq', umamiEventLabel: 'faq' },
|
||||
{
|
||||
label: 'Book a call',
|
||||
href: `${discoveryCallUrl}-navigation`,
|
||||
umamiEventLabel: 'book-call',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'when-teams',
|
||||
headingId: 'when-teams-heading',
|
||||
heading: 'When a team needs technical leadership—but not a full-time CTO',
|
||||
paragraphs: [
|
||||
'Most early-stage SaaS companies reach a moment where shipping features is no longer the only challenge. Architecture decisions start to compound. Tooling choices matter. Hiring decisions become technical decisions.',
|
||||
'But hiring a full-time CTO is often premature.',
|
||||
'A fractional CTO provides senior-level guidance without the cost and commitment of a full-time executive.',
|
||||
'Typical signs a team is here:',
|
||||
],
|
||||
bullets: [
|
||||
'The codebase is growing quickly and architectural decisions are becoming harder to reverse',
|
||||
'Founders want a second opinion on technical tradeoffs',
|
||||
'Engineering teams are shipping but lack architectural guardrails',
|
||||
'Tooling and infrastructure decisions feel arbitrary',
|
||||
'Hiring engineers without a clear technical standard',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'failure-modes',
|
||||
headingId: 'failure-modes-heading',
|
||||
heading: 'The two ways early-stage teams usually get it wrong',
|
||||
paragraphs: ['I see two patterns repeatedly.'],
|
||||
subsections: [
|
||||
{
|
||||
heading: 'Over-building too early',
|
||||
paragraphs: [
|
||||
"Teams adopt infrastructure and services designed for companies ten times their size. They spend time and capital building systems they don't yet need.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Shipping chaos',
|
||||
paragraphs: [
|
||||
'Other teams rush an MVP by outsourcing development cheaply, producing something that works—but is extremely difficult to iterate on.',
|
||||
],
|
||||
},
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'Healthy early-stage engineering balances both pressures: lean spending with solid foundations.',
|
||||
],
|
||||
footerLinks: [
|
||||
{
|
||||
label: 'Hands-on SaaS architecture',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
label: 'MVP architecture and launch',
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
label: 'Stage-aligned infrastructure',
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'approach',
|
||||
headingId: 'approach-heading',
|
||||
heading: 'What working with me looks like',
|
||||
paragraphs: [
|
||||
'I operate as a technical partner for founders and engineering teams.',
|
||||
'That means I help with both strategic decisions and practical implementation.',
|
||||
'Typical involvement includes:',
|
||||
],
|
||||
bullets: [
|
||||
'Reviewing architecture decisions before they become expensive mistakes',
|
||||
'Participating in product and roadmap discussions',
|
||||
'Setting architectural guardrails for the engineering team',
|
||||
'Evaluating tooling and infrastructure choices',
|
||||
'Mentoring developers and improving engineering practices',
|
||||
'Refactoring critical parts of the codebase when necessary',
|
||||
'Helping founders understand the technical tradeoffs behind decisions',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'I am not a slide-deck CTO. I stay close enough to the code to keep decisions grounded in reality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-changes',
|
||||
headingId: 'what-changes-heading',
|
||||
heading: 'What changes when technical leadership is present',
|
||||
paragraphs: [
|
||||
'When a team has clear technical leadership, several things happen quickly:',
|
||||
],
|
||||
bullets: [
|
||||
'Architectural decisions become intentional instead of reactive',
|
||||
'Developers move faster because the system has clearer structure',
|
||||
'Tooling choices become consistent',
|
||||
'Technical discussions shift from opinion to reasoning',
|
||||
'Engineers spend more time building and less time debating direction',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'The result is not just cleaner systems—but a calmer engineering culture.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headingId: 'engagement-heading',
|
||||
heading: 'How fractional CTO engagements usually work',
|
||||
paragraphs: [
|
||||
'Engagements are typically structured as a monthly retainer with defined availability.',
|
||||
"Typical involvement ranges between 10–40 hours per month depending on the company's stage and needs.",
|
||||
'This usually includes:',
|
||||
],
|
||||
bullets: [
|
||||
'Regular founder check-ins',
|
||||
'Engineering architecture reviews',
|
||||
'Product roadmap discussions',
|
||||
'Codebase and system reviews',
|
||||
'Guidance on hiring and technical standards',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'The goal is consistent oversight without becoming a bottleneck.',
|
||||
],
|
||||
},
|
||||
],
|
||||
who: {
|
||||
whoForList: [
|
||||
'Founder-led SaaS companies',
|
||||
'1–15 engineers',
|
||||
'Teams preparing to scale their product',
|
||||
'Companies that want technical leadership without a full-time CTO',
|
||||
],
|
||||
whoNotList: [
|
||||
'Companies that already have strong senior technical leadership',
|
||||
'Enterprises needing full-time executive presence',
|
||||
'Teams looking only for implementation without architectural guidance',
|
||||
],
|
||||
},
|
||||
scheduleCta: {
|
||||
sectionId: 'final-cta',
|
||||
headingId: 'final-cta-heading',
|
||||
title: 'Need a technical adult in the room?',
|
||||
subtitle:
|
||||
'If your team is shipping quickly but architectural decisions are starting to feel heavier, a fractional CTO engagement can provide the guidance needed to keep the system healthy while the product grows.',
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: `${discoveryCallUrl}-schedule-section`,
|
||||
showEmailLink: true,
|
||||
},
|
||||
meta: {
|
||||
title: 'Fractional CTO for Early-Stage SaaS | mifi Ventures',
|
||||
description:
|
||||
'Technical leadership for early-stage SaaS teams that need architectural direction without hiring a full-time CTO. Hands-on guidance that keeps teams shipping while preventing structural mistakes.',
|
||||
jsonLdServiceDescription:
|
||||
'Fractional CTO and technical partner for early-stage SaaS: architectural direction, codebase and tooling guidance, and hands-on leadership without the cost of a full-time CTO. Monthly retainer engagements.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FAQ for the fractional CTO for early-stage SaaS service page.
|
||||
* Single source of truth for both the page content and FAQPage JSON-LD.
|
||||
*/
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: 'What does "fractional CTO" actually mean?',
|
||||
answer: 'A fractional CTO is a part-time technical leader who provides CTO-level guidance without the cost of a full-time executive. You get senior architectural direction, decision review, and technical oversight on a retainer basis—typically 10–40 hours per month depending on your stage.',
|
||||
},
|
||||
{
|
||||
question: 'How involved are you in day-to-day engineering?',
|
||||
answer: 'I stay close enough to the code to keep decisions grounded. That means architecture reviews, participation in product and roadmap discussions, and hands-on refactoring when it matters. I’m not a slide-deck CTO—I operate as a technical partner who can both advise and implement.',
|
||||
},
|
||||
{
|
||||
question: 'Can you still contribute code?',
|
||||
answer: 'Yes. I contribute code when it’s the right leverage: critical refactors, foundation work, or examples that set standards for the team. The goal is to improve the system and transfer knowledge, not to become a permanent implementer.',
|
||||
},
|
||||
{
|
||||
question: 'How long do engagements usually last?',
|
||||
answer: 'Engagements are typically ongoing month-to-month. Some teams need fractional leadership for 6–12 months until they hire a full-time CTO; others stay on a retainer longer. We align on goals and revisit as your team grows.',
|
||||
},
|
||||
{
|
||||
question: 'Can you help with hiring engineers?',
|
||||
answer: 'Yes. I help define technical standards, review candidates, and advise on team structure. Hiring decisions are technical decisions—having a clear bar and someone who can assess it makes a big difference.',
|
||||
},
|
||||
{
|
||||
question: 'What tech stacks do you work with?',
|
||||
answer: 'I work with modern web and SaaS stacks—frontend, backend, and infrastructure. The principles of good architecture (separation of concerns, maintainability, stage-appropriate choices) transfer across stacks. We can discuss fit for your stack.',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { ServiceDetailContent } from '$lib/types/service-page';
|
||||
|
||||
const discoveryCallUrl =
|
||||
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=hands_on_saas_arch_page';
|
||||
|
||||
export const pageContent: ServiceDetailContent = {
|
||||
hero: {
|
||||
title: 'Hands-On Product Architecture for Early-Stage SaaS',
|
||||
subtitle:
|
||||
"Most early-stage teams obsess over backend performance and treat the frontend as a thin layer. That's backwards. The frontend is the product—and if its foundations aren't built to scale, iteration slows, bugs multiply, and shipping becomes stressful. I work inside your codebase to build frontend systems that enable velocity: clean CSS architecture, reusable components, tokenized design systems, and accessibility from day one.",
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: discoveryCallUrl,
|
||||
secondaryCta: {
|
||||
href: '#approach',
|
||||
label: 'See my approach',
|
||||
umamiEventLabel: 'see my approach',
|
||||
},
|
||||
},
|
||||
tocItems: [
|
||||
{ label: 'Credibility', href: '#credibility' },
|
||||
{ label: 'Why this matters', href: '#why-this-matters' },
|
||||
{ label: 'The frontend is the product', href: '#frontend-is-product' },
|
||||
{ label: 'What goes wrong', href: '#what-goes-wrong' },
|
||||
{ label: 'My approach', href: '#approach' },
|
||||
{ label: 'Outcomes', href: '#outcomes' },
|
||||
{ label: 'Engagement options', href: '#engagement' },
|
||||
{ label: "Who it's for", href: '#who-its-for' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
{ label: 'Get in touch', href: '#final-cta' },
|
||||
],
|
||||
navItems: [
|
||||
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
|
||||
{ label: 'My approach', href: '#approach', umamiEventLabel: 'approach' },
|
||||
{ label: 'Outcomes', href: '#outcomes', umamiEventLabel: 'outcomes' },
|
||||
{ label: 'Engagement', href: '#engagement', umamiEventLabel: 'engagement' },
|
||||
{ label: 'FAQ', href: '#faq', umamiEventLabel: 'faq' },
|
||||
{
|
||||
label: 'Book a call',
|
||||
href: `${discoveryCallUrl}-navigation`,
|
||||
umamiEventLabel: 'book-call',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'credibility',
|
||||
heading: 'Credibility',
|
||||
headingSrOnly: true,
|
||||
sectionClass: 'service-credibility',
|
||||
narrowContainer: false,
|
||||
bulletsListClass: 'service-credibility__list',
|
||||
bullets: [
|
||||
'Product developer at heart; systems-minded by default',
|
||||
'Frontend foundations: CSS architecture, design tokens, component libraries',
|
||||
'Accessibility-first (bolting it on later is always expensive)',
|
||||
'Stage-aware decisions that preserve runway without slowing shipping',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'why-this-matters',
|
||||
headingId: 'why-heading',
|
||||
heading: "Shipping slows down quietly—until it suddenly doesn't",
|
||||
paragraphs: [
|
||||
"Early-stage SaaS rarely fails because the backend can't handle load. It fails because iteration becomes painful.",
|
||||
'When the frontend is ad hoc—CSS drift, inconsistent components, no tokens, accessibility bolted on later—every new feature costs more than it should. Engineers hesitate to touch code. Small changes break unrelated screens. Design becomes inconsistent. Velocity drops.',
|
||||
'This is not a "design problem." It\'s an architecture problem.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'frontend-is-product',
|
||||
headingId: 'frontend-heading',
|
||||
heading: 'The frontend is the product',
|
||||
paragraphs: [
|
||||
"Your customers don't experience your database schema. They experience your UI.",
|
||||
"A performant backend matters—but it's not more important than a frontend built for iteration. The frontend drives conversion, communicates trust, and determines how quickly your team can ship new features without breaking old ones.",
|
||||
'If you want to move fast later, you have to build a foundation now.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-goes-wrong',
|
||||
headingId: 'wrong-heading',
|
||||
heading: 'What I see most often',
|
||||
bullets: [
|
||||
'Backend-crafted frontends: functional, but brittle and inconsistent',
|
||||
'"CSS as an afterthought": global overrides, magic numbers, creeping specificity wars',
|
||||
"Component sprawl: dozens of one-off components that can't be reused",
|
||||
'No design tokens: colors, spacing, typography duplicated everywhere',
|
||||
'Accessibility postponed: expensive retrofits, inconsistent semantics',
|
||||
'Rebuilding instead of composing: no "lego system," so iteration is slow and repetitive',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'approach',
|
||||
headingId: 'approach-heading',
|
||||
heading: 'My approach: architecture through implementation',
|
||||
paragraphs: [
|
||||
"I'm not a diagram consultant. I'm hands-on. I get into the codebase and build the foundation your team can extend.",
|
||||
'The goal is simple: make iteration cheap.',
|
||||
],
|
||||
subsections: [
|
||||
{
|
||||
heading: '1) Fix the CSS foundation first',
|
||||
paragraphs: [
|
||||
'Bad CSS makes everything inconsistent and makes change arduous. I start by establishing a maintainable CSS architecture that supports a coherent visual language and reduces the bug surface area.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2) Build a reusable component system',
|
||||
paragraphs: [
|
||||
'I create a component library that lowers duplication and prevents developers from "re-solving" the same UI problems. This increases speed and improves consistency.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3) Tokenize the design system',
|
||||
paragraphs: [
|
||||
'Design tokens (color, spacing, typography, radii, shadows) allow the brand to evolve without rewrites. You can iterate quickly as you discover what your product should feel like.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4) Bake in accessibility from day one',
|
||||
paragraphs: [
|
||||
'Accessibility isn\'t optional and it isn\'t "later." Bolting it on later is always expensive. Doing it early saves time, improves UX, and strengthens SEO.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5) Keep the whole system stage-aligned',
|
||||
paragraphs: [
|
||||
"I'm pragmatic about tooling. Some things can stay on free tiers early. Some choices should be intentional because migration later is painful. The point is to preserve runway without slowing shipping.",
|
||||
],
|
||||
},
|
||||
],
|
||||
footerLinks: [
|
||||
{
|
||||
label: 'Fractional CTO for early-stage SaaS',
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
{
|
||||
label: 'MVP architecture and launch',
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
label: 'Stage-aligned infrastructure',
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'outcomes',
|
||||
headingId: 'outcomes-heading',
|
||||
heading: 'What changes when the foundation is right',
|
||||
paragraphs: [
|
||||
"It's all connected—speed, bugs, confidence, and morale are one big ball of yarn. When the frontend foundation is right, the whole system gets calmer.",
|
||||
],
|
||||
bullets: [
|
||||
'Developers ship faster with less hesitation',
|
||||
'Lower bug rate from fewer one-off implementations',
|
||||
'Cohesive UI and clearer product identity',
|
||||
'Faster design iteration without rewrites',
|
||||
'Accessibility becomes the default, not a retrofit',
|
||||
'SEO improves naturally via good structure and semantics',
|
||||
'Higher release confidence and better team morale',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headingId: 'engagement-heading',
|
||||
heading: 'How we can work together',
|
||||
subsections: [
|
||||
{
|
||||
heading: 'Architecture engagement (high-fee, fixed scope)',
|
||||
bullets: [
|
||||
'Codebase review focused on frontend foundations',
|
||||
'CSS + component audit, prioritized action plan',
|
||||
'Token strategy and incremental adoption plan',
|
||||
'Accessibility baseline and standards',
|
||||
'A roadmap your team can execute',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Implementation (optional, bounded)',
|
||||
bullets: [
|
||||
'Hands-on refactors and foundation building',
|
||||
'Component library creation and rollout',
|
||||
'Tokenization and theming support',
|
||||
"Documentation and handoff so you're not dependent on me",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Advisory retainer (recurring, low-liability)',
|
||||
bullets: [
|
||||
'Regular check-ins to keep architecture clean as you ship',
|
||||
'Review of component/API decisions and PRs (optional)',
|
||||
'Guidance on tooling tradeoffs as you grow',
|
||||
'No 24/7 pager-duty MSP model',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
who: {
|
||||
whoForHeading: 'Who this is for',
|
||||
whoForList: [
|
||||
'Founder-led SaaS teams',
|
||||
'1–15 engineers (and growing)',
|
||||
'Teams shipping fast but feeling UI/UX friction',
|
||||
'Teams who want adult-level architecture without slowing down',
|
||||
],
|
||||
whoNotList: [
|
||||
'Organizations looking for pure strategy decks and diagrams',
|
||||
'Teams wanting a long-term embedded full-time DevOps engineer',
|
||||
'Companies needing 24/7 managed hosting with strict SLAs',
|
||||
],
|
||||
},
|
||||
scheduleCta: {
|
||||
sectionId: 'final-cta',
|
||||
headingId: 'final-cta-heading',
|
||||
title: 'Want to ship faster without frontend debt?',
|
||||
subtitle:
|
||||
"If your UI feels brittle, inconsistent, or slow to evolve, you're not alone—and you don't need a massive rewrite. Let's build a foundation that makes iteration cheap.",
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: `${discoveryCallUrl}-schedule-section`,
|
||||
showEmailLink: true,
|
||||
},
|
||||
meta: {
|
||||
title: 'SaaS Product Architecture Consultant | mifi Ventures',
|
||||
description:
|
||||
'I help early-stage SaaS teams ship faster by building scalable frontend foundations—clean CSS, component libraries, design tokens, and accessibility from day one—so iteration accelerates instead of slowing down.',
|
||||
jsonLdServiceDescription:
|
||||
'Hands-on product architecture for early-stage SaaS: frontend foundations, CSS architecture, component libraries, design tokens, and accessibility from day one. Architecture engagement, implementation support, and advisory retainer.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* FAQ for the hands-on SaaS architecture consultant service page.
|
||||
* Single source of truth for both the page content and FAQPage JSON-LD.
|
||||
*/
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: 'Do you only do frontend?',
|
||||
answer: "No. I'm product-first with backend and infra awareness—but frontend foundations are often the bottleneck for early-stage teams. I focus where the friction is.",
|
||||
},
|
||||
{
|
||||
question: 'Will this slow us down?',
|
||||
answer: 'No. The goal is to make iteration cheaper. A solid foundation reduces rework and lets you ship faster over time.',
|
||||
},
|
||||
{
|
||||
question: 'Do we need a full redesign?',
|
||||
answer: 'Usually no. I take an incremental foundation approach: fix the architecture and systems first, then evolve the UI. Big-bang redesigns are rarely necessary.',
|
||||
},
|
||||
{
|
||||
question: 'What tech stacks do you work with?',
|
||||
answer: 'I work with modern web stacks—React, Vue, Svelte, and similar. The principles (CSS architecture, tokens, components, accessibility) transfer. We can discuss fit for your stack.',
|
||||
},
|
||||
{
|
||||
question: 'How do you handle accessibility?',
|
||||
answer: "Baseline standards (semantic markup, keyboard, focus, ARIA where needed) plus components built accessibly from the start. It's baked into the system, not bolted on.",
|
||||
},
|
||||
{
|
||||
question: 'Can you work with our designer?',
|
||||
answer: 'Yes. Design tokens and a component system give designers and engineers a shared language. I can align with your design process and tools.',
|
||||
},
|
||||
{
|
||||
question: 'Do you offer ongoing support?',
|
||||
answer: 'Yes, via an advisory retainer: regular check-ins, optional PR review, and guidance as you grow. Bounded and lightweight—not a 24/7 MSP model.',
|
||||
},
|
||||
];
|
||||
103
src/lib/data/services/landing/content.ts
Normal file
103
src/lib/data/services/landing/content.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ServicesLandingContent } from '$lib/types/service-page';
|
||||
|
||||
const discoveryCallUrl =
|
||||
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=services_landing';
|
||||
|
||||
export const pageContent: ServicesLandingContent = {
|
||||
hero: {
|
||||
title: 'Consulting Services',
|
||||
subtitle:
|
||||
'I work with early-stage SaaS teams to build products that ship quickly and evolve cleanly. Engagements range from hands-on architecture work inside your codebase to technical leadership for growing teams.',
|
||||
bookingLinkTitle: 'Schedule a discovery call',
|
||||
bookingLinkUrl: discoveryCallUrl,
|
||||
secondaryCta: {
|
||||
href: '/#process',
|
||||
label: 'See how I work',
|
||||
umamiEventLabel: 'see how i work',
|
||||
},
|
||||
},
|
||||
navItems: [
|
||||
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
|
||||
{ label: 'Services', href: '#services-grid', umamiEventLabel: 'services' },
|
||||
{
|
||||
label: 'How engagements work',
|
||||
href: '#how-engagements-work',
|
||||
umamiEventLabel: 'engagements',
|
||||
},
|
||||
{
|
||||
label: 'Book a call',
|
||||
href: `${discoveryCallUrl}-navigation`,
|
||||
umamiEventLabel: 'book-call',
|
||||
},
|
||||
],
|
||||
introParagraphs: [
|
||||
'Early-stage SaaS companies face different technical challenges as they grow. Some need help building their MVP correctly. Others need architectural guidance as complexity increases. Some teams simply need a senior engineer who can step in and stabilize a chaotic codebase.',
|
||||
'The services below represent the most common ways I work with founders and engineering teams.',
|
||||
],
|
||||
services: [
|
||||
{
|
||||
title: 'Hands-On SaaS Architecture',
|
||||
description:
|
||||
'Build the foundations that allow SaaS products to evolve without accumulating structural debt. I work directly inside your codebase to improve frontend systems, establish reusable components, and create architecture that supports long-term iteration.',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
title: 'MVP Architecture & Launch',
|
||||
description:
|
||||
"Ship your product quickly without creating a fragile system you'll have to rewrite six months later. I help teams design and build MVPs that are simple, scalable, and structured for rapid iteration.",
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
title: 'Fractional CTO / Technical Partner',
|
||||
description:
|
||||
"Technical leadership for teams that need architectural direction but aren't ready for a full-time CTO. I work alongside founders and engineers to guide system design, evaluate technical decisions, and maintain long-term architectural clarity.",
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
{
|
||||
title: 'Stage-Aligned Infrastructure',
|
||||
description:
|
||||
"Infrastructure decisions should match your company's stage. I help teams avoid unnecessary SaaS sprawl and cloud complexity while building infrastructure that can grow with the product.",
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
],
|
||||
engagementsIntro: 'Most engagements fall into one of three patterns:',
|
||||
engagements: [
|
||||
{
|
||||
term: 'Architecture engagements',
|
||||
definition:
|
||||
'Focused, fixed-scope projects designed to diagnose and correct structural issues in a codebase.',
|
||||
},
|
||||
{
|
||||
term: 'Implementation work',
|
||||
definition: 'Hands-on engineering to build or refactor core product systems.',
|
||||
},
|
||||
{
|
||||
term: 'Advisory retainers',
|
||||
definition:
|
||||
'Ongoing architectural guidance for teams that need senior technical oversight.',
|
||||
},
|
||||
],
|
||||
engagementsOutro:
|
||||
'Engagements are typically consulting-led and scoped to deliver meaningful progress quickly.',
|
||||
idealClients: [
|
||||
'Founder-led SaaS startups',
|
||||
'Engineering teams with 1–15 developers',
|
||||
'Products that are actively shipping and evolving',
|
||||
'Teams that value thoughtful engineering decisions',
|
||||
],
|
||||
scheduleCta: {
|
||||
title: 'Not sure which service fits?',
|
||||
subtitle:
|
||||
'Many engagements start with a short conversation about your product and technical challenges. From there we can determine whether architecture work, MVP support, or ongoing technical leadership makes the most sense.',
|
||||
bookingLinkTitle: 'Schedule a discovery call',
|
||||
bookingLinkUrl: `${discoveryCallUrl}-schedule-section`,
|
||||
showEmailLink: true,
|
||||
},
|
||||
meta: {
|
||||
title: 'SaaS Architecture Services | mifi Ventures',
|
||||
description:
|
||||
'Hands-on SaaS architecture consulting for early-stage teams. Services include product architecture, MVP launch support, fractional CTO guidance, and stage-aligned infrastructure strategy.',
|
||||
jsonLdServiceDescription:
|
||||
'SaaS architecture consulting for early-stage teams: hands-on product architecture, MVP launch support, fractional CTO, and stage-aligned infrastructure strategy.',
|
||||
},
|
||||
};
|
||||
195
src/lib/data/services/mvp-architecture-and-launch/content.ts
Normal file
195
src/lib/data/services/mvp-architecture-and-launch/content.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { ServiceDetailContent } from '$lib/types/service-page';
|
||||
|
||||
const discoveryCallUrl =
|
||||
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=mvp_arch_launch_page';
|
||||
|
||||
export const pageContent: ServiceDetailContent = {
|
||||
hero: {
|
||||
title: 'MVP Architecture & Launch for Early-Stage SaaS',
|
||||
subtitle:
|
||||
'Shipping fast is good. Shipping chaos is expensive. I help early-stage SaaS teams build MVPs that move quickly without creating frontend debt, fragile CSS, or structural problems that slow iteration six months later.',
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: discoveryCallUrl,
|
||||
secondaryCta: {
|
||||
href: '#approach',
|
||||
label: 'See how I work',
|
||||
umamiEventLabel: 'see how i work',
|
||||
},
|
||||
},
|
||||
tocItems: [
|
||||
{ label: 'The common MVP pattern', href: '#common-pattern' },
|
||||
{ label: 'What a good MVP foundation looks like', href: '#good-foundation' },
|
||||
{ label: 'My approach', href: '#approach' },
|
||||
{ label: 'What changes within 1–2 weeks', href: '#what-changes' },
|
||||
{ label: 'Engagement options', href: '#engagement' },
|
||||
{ label: "Who it's for", href: '#who-its-for' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
{ label: 'Get in touch', href: '#final-cta' },
|
||||
],
|
||||
navItems: [
|
||||
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
|
||||
{ label: 'My approach', href: '#approach', umamiEventLabel: 'approach' },
|
||||
{ label: 'Engagement', href: '#engagement', umamiEventLabel: 'engagement' },
|
||||
{ label: 'FAQ', href: '#faq', umamiEventLabel: 'faq' },
|
||||
{
|
||||
label: 'Book a call',
|
||||
href: `${discoveryCallUrl}-navigation`,
|
||||
umamiEventLabel: 'book-call',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'common-pattern',
|
||||
headingId: 'common-pattern-heading',
|
||||
heading: 'Most MVPs are built for speed—few are built for iteration',
|
||||
paragraphs: [
|
||||
'Early MVPs often prioritize backend logic and feature delivery. The frontend becomes an afterthought—functional, but brittle. Six months later, every new feature feels heavier than the last.',
|
||||
'Common symptoms:',
|
||||
],
|
||||
bullets: [
|
||||
'Poor separation of concerns',
|
||||
'Backend-heavy architecture with fragile UI',
|
||||
'Repeated components instead of reusable systems',
|
||||
'Spaghetti CSS and specificity wars',
|
||||
'Accessibility postponed',
|
||||
'"We\'ll clean it up later" decisions compounding',
|
||||
],
|
||||
trailingParagraphs: ["Speed isn't the problem. Structure is."],
|
||||
},
|
||||
{
|
||||
id: 'good-foundation',
|
||||
headingId: 'good-foundation-heading',
|
||||
heading: 'MVP does not mean throwaway',
|
||||
paragraphs: ['A well-built MVP is minimal—but intentional.', 'It includes:'],
|
||||
bullets: [
|
||||
'Clear separation between layers',
|
||||
'Reusable, composable frontend components',
|
||||
'Tokenized design systems (color, spacing, typography)',
|
||||
'Clean, maintainable CSS architecture',
|
||||
'Accessibility baked in from day one',
|
||||
'A simple, predictable deployment path',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'You can move fast and build correctly at the same time.',
|
||||
],
|
||||
footerLinks: [
|
||||
{
|
||||
label: 'Hands-on SaaS architecture',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
label: 'Fractional CTO for early-stage SaaS',
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
{
|
||||
label: 'Stage-aligned infrastructure',
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'approach',
|
||||
headingId: 'approach-heading',
|
||||
heading: 'Architecture through implementation',
|
||||
paragraphs: [
|
||||
"I don't deliver diagrams and disappear. I work inside your codebase.",
|
||||
'My approach:',
|
||||
],
|
||||
orderedBullets: [
|
||||
'Fix the CSS foundation first.',
|
||||
'Extract and standardize reusable components.',
|
||||
'Introduce design tokens to prevent duplication.',
|
||||
'Align frontend and backend boundaries.',
|
||||
'Improve accessibility and semantics incrementally.',
|
||||
'Keep shipping while refactoring.',
|
||||
],
|
||||
trailingParagraphs: ['No rewrite mandates. No velocity freeze.'],
|
||||
},
|
||||
{
|
||||
id: 'what-changes',
|
||||
headingId: 'what-changes-heading',
|
||||
heading: 'What teams notice quickly',
|
||||
paragraphs: [
|
||||
'In most cases, teams feel the difference within 1–2 weeks once foundational issues are corrected.',
|
||||
"You'll see:",
|
||||
],
|
||||
bullets: [
|
||||
'Faster feature implementation',
|
||||
'Lower bug rates',
|
||||
'More consistent UI',
|
||||
'Safer refactors',
|
||||
'Increased release confidence',
|
||||
'Better team morale',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
"It's all one big ball of yarn—clean up the foundation and everything moves more smoothly.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headingId: 'engagement-heading',
|
||||
heading: 'How we can work together',
|
||||
subsections: [
|
||||
{
|
||||
heading: 'MVP Architecture Engagement (fixed scope)',
|
||||
bullets: [
|
||||
'Codebase review focused on frontend foundations',
|
||||
'Structural audit and prioritized roadmap',
|
||||
'Component system extraction plan',
|
||||
'CSS cleanup and token strategy',
|
||||
'Accessibility baseline',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Hands-On Implementation (optional)',
|
||||
bullets: [
|
||||
'Direct refactoring and component system creation',
|
||||
'Tokenized design system rollout',
|
||||
'Pairing with your engineers',
|
||||
'Documentation and knowledge transfer',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Ongoing Advisory (optional)',
|
||||
bullets: [
|
||||
'Periodic architecture reviews',
|
||||
'Guardrails as you scale',
|
||||
'Guidance on feature/system tradeoffs',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
who: {
|
||||
whoForHeading: 'Ideal fit',
|
||||
whoForList: [
|
||||
'Founder-led SaaS teams',
|
||||
'1–10 engineers',
|
||||
'Recently launched MVP',
|
||||
'Feeling UI friction or code fragility',
|
||||
'Want adult-level architecture without slowing down',
|
||||
],
|
||||
whoNotList: [
|
||||
'Teams who only want features shipped as fast as possible without regard for structure',
|
||||
'Organizations looking purely for architecture slide decks',
|
||||
'Large enterprises needing formal procurement processes',
|
||||
],
|
||||
},
|
||||
scheduleCta: {
|
||||
sectionId: 'final-cta',
|
||||
headingId: 'final-cta-heading',
|
||||
title: 'Ready to stabilize your MVP?',
|
||||
subtitle:
|
||||
"If your MVP shipped fast but now feels fragile, let's reinforce the foundation before iteration slows further.",
|
||||
bookingLinkTitle: 'Schedule a discovery call',
|
||||
bookingLinkUrl: `${discoveryCallUrl}-schedule-section`,
|
||||
showEmailLink: true,
|
||||
},
|
||||
meta: {
|
||||
title: 'MVP Architecture & Launch Consultant | mifi Ventures',
|
||||
description:
|
||||
'I help early-stage SaaS teams build and stabilize MVPs with clean frontend foundations, reusable components, and scalable architecture—so you can ship fast without creating chaos six months later.',
|
||||
jsonLdServiceDescription:
|
||||
'MVP architecture and launch for early-stage SaaS: clean foundations, reusable components, tokenized design systems, and accessibility from day one. Fixed-scope engagement, hands-on implementation, and optional advisory.',
|
||||
},
|
||||
};
|
||||
40
src/lib/data/services/mvp-architecture-and-launch/faq.ts
Normal file
40
src/lib/data/services/mvp-architecture-and-launch/faq.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* FAQ for the MVP architecture and launch service page.
|
||||
* Single source of truth for both the page content and FAQPage JSON-LD.
|
||||
*/
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: 'Will this slow us down?',
|
||||
answer: 'No. The goal is to make iteration cheaper. I work incrementally—fixing foundations while you keep shipping. No rewrite mandates, no velocity freeze.',
|
||||
},
|
||||
{
|
||||
question: 'Do we need a full rewrite?',
|
||||
answer: 'Usually no. Most MVPs need structural cleanup and a clear component/CSS strategy, not a from-scratch rebuild. We prioritize the highest-leverage fixes first.',
|
||||
},
|
||||
{
|
||||
question: 'What tech stacks do you work with?',
|
||||
answer: 'I work with modern web stacks—React, Vue, Svelte, and similar. The principles (separation of concerns, tokens, components, accessibility) transfer. We can discuss fit for your stack.',
|
||||
},
|
||||
{
|
||||
question: 'How do you handle accessibility?',
|
||||
answer: 'Accessibility is baked in incrementally: semantic markup, keyboard and focus behavior, and ARIA where needed. Doing it as we refactor is far cheaper than retrofitting later.',
|
||||
},
|
||||
{
|
||||
question: 'Can you work alongside our existing devs?',
|
||||
answer: 'Yes. I pair with your team, document decisions, and hand off so you’re not dependent on me. Knowledge transfer is part of the engagement.',
|
||||
},
|
||||
{
|
||||
question: 'How quickly will we see impact?',
|
||||
answer: 'Most teams feel the difference within 1–2 weeks once foundational issues are addressed: faster feature work, fewer bugs, and safer refactors.',
|
||||
},
|
||||
{
|
||||
question: 'Do you only focus on frontend?',
|
||||
answer: "I'm product-first. Frontend foundations are often the bottleneck for early MVPs, so that's where I focus—but I align with your backend boundaries and deployment so the whole system makes sense.",
|
||||
},
|
||||
];
|
||||
185
src/lib/data/services/stage-aligned-infrastructure/content.ts
Normal file
185
src/lib/data/services/stage-aligned-infrastructure/content.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { ServiceDetailContent } from '$lib/types/service-page';
|
||||
|
||||
const discoveryCallUrl =
|
||||
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=stage_aligned_infra_page';
|
||||
|
||||
export const pageContent: ServiceDetailContent = {
|
||||
hero: {
|
||||
title: 'Stage-Aligned Infrastructure for Early-Stage SaaS',
|
||||
subtitle:
|
||||
'Infrastructure should match the stage of your company. Many early SaaS teams inherit complex cloud architectures and SaaS stacks designed for companies ten times their size. The result is predictable: large monthly bills and operational complexity that slows engineering teams down. I help founders design infrastructure that is simple, predictable, and able to evolve as the product grows.',
|
||||
bookingLinkTitle: 'Book a discovery call',
|
||||
bookingLinkUrl: discoveryCallUrl,
|
||||
secondaryCta: {
|
||||
href: '#approach',
|
||||
label: 'See my approach',
|
||||
umamiEventLabel: 'see my approach',
|
||||
},
|
||||
},
|
||||
tocItems: [
|
||||
{ label: 'The early-stage infrastructure trap', href: '#trap' },
|
||||
{ label: 'The real cost', href: '#real-cost' },
|
||||
{ label: 'What stage-aligned looks like', href: '#what-it-looks-like' },
|
||||
{ label: 'Seeded cloud', href: '#seeded-cloud' },
|
||||
{ label: 'When cloud services make sense', href: '#when-cloud' },
|
||||
{ label: 'How I help', href: '#approach' },
|
||||
{ label: "Who it's for", href: '#who-its-for' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
{ label: 'Get in touch', href: '#final-cta' },
|
||||
],
|
||||
navItems: [
|
||||
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
|
||||
{ label: 'My approach', href: '#approach', umamiEventLabel: 'approach' },
|
||||
{ label: 'FAQ', href: '#faq', umamiEventLabel: 'faq' },
|
||||
{
|
||||
label: 'Book a call',
|
||||
href: `${discoveryCallUrl}-navigation`,
|
||||
umamiEventLabel: 'book-call',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'trap',
|
||||
headingId: 'trap-heading',
|
||||
heading: 'How early-stage companies end up with enterprise infrastructure',
|
||||
paragraphs: [
|
||||
'Many startups adopt infrastructure patterns designed for much larger organizations.',
|
||||
'Common causes include:',
|
||||
],
|
||||
bullets: [
|
||||
'Copying architectures from big tech companies',
|
||||
'Following tutorials designed for enterprise-scale systems',
|
||||
'Adding cloud services incrementally without a clear strategy',
|
||||
'Believing that complex infrastructure signals maturity',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'The result is often a patchwork of services that are expensive to run and difficult to reason about.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'real-cost',
|
||||
headingId: 'real-cost-heading',
|
||||
heading: 'The hidden cost of infrastructure complexity',
|
||||
paragraphs: [
|
||||
'Infrastructure complexity has two costs: money and cognitive load.',
|
||||
'Founders frequently notice the first sign when monthly cloud or SaaS bills grow unexpectedly. But the second cost can be even more damaging.',
|
||||
'Engineers begin spending time navigating infrastructure instead of building product. Debugging becomes harder. Deployments become fragile. Small teams are suddenly maintaining systems designed for companies with dedicated platform teams.',
|
||||
'Infrastructure should enable product development, not compete with it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-it-looks-like',
|
||||
headingId: 'what-heading',
|
||||
heading: 'Infrastructure that grows with the product',
|
||||
paragraphs: [
|
||||
'Stage-aligned infrastructure starts simple and becomes more sophisticated only when necessary.',
|
||||
'For many early products, a straightforward deployment environment can support far more growth than founders expect. Simple environments are easier to understand, easier to maintain, and far less expensive.',
|
||||
'As the product scales, infrastructure can evolve deliberately rather than reactively.',
|
||||
'The goal is not minimalism for its own sake. The goal is intentional complexity.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'seeded-cloud',
|
||||
headingId: 'seeded-cloud-heading',
|
||||
heading: 'Seeded Cloud Infrastructure',
|
||||
paragraphs: [
|
||||
'I sometimes describe this approach as "seeded cloud."',
|
||||
'Instead of inheriting a massive ecosystem of services from day one, companies plant a small, understandable infrastructure foundation that can grow over time.',
|
||||
'This might include:',
|
||||
],
|
||||
bullets: [
|
||||
'Simple compute environments',
|
||||
'Predictable deployment pipelines',
|
||||
'Minimal operational overhead',
|
||||
'Tooling chosen for clarity and maintainability',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'As the product grows, that foundation can expand naturally into more sophisticated architectures.',
|
||||
'Infrastructure evolves when scale requires it—not before.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'when-cloud',
|
||||
headingId: 'when-cloud-heading',
|
||||
heading: 'When managed cloud services are the right choice',
|
||||
paragraphs: [
|
||||
'Cloud platforms and SaaS tools exist for good reasons. For some companies and stages they are absolutely the right decision.',
|
||||
'Examples include:',
|
||||
],
|
||||
bullets: [
|
||||
'Teams operating at significant scale',
|
||||
'Organizations with compliance requirements',
|
||||
'Products requiring specialized infrastructure capabilities',
|
||||
'Companies with dedicated platform engineering teams',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'The key is adopting these tools deliberately, not reflexively.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'approach',
|
||||
headingId: 'approach-heading',
|
||||
heading: 'How I help teams realign their infrastructure',
|
||||
paragraphs: [
|
||||
'My work typically focuses on helping founders and engineering teams simplify their infrastructure while preserving the ability to grow.',
|
||||
'Typical engagements include:',
|
||||
],
|
||||
bullets: [
|
||||
'Infrastructure audits and simplification',
|
||||
'Evaluating cloud and SaaS tool usage',
|
||||
'Identifying unnecessary complexity',
|
||||
'Designing simpler deployment patterns',
|
||||
'Aligning infrastructure with product stage',
|
||||
'Planning transitions as scale increases',
|
||||
],
|
||||
trailingParagraphs: [
|
||||
'The goal is not rebuilding everything. The goal is restoring clarity and intentionality.',
|
||||
],
|
||||
footerLinks: [
|
||||
{
|
||||
label: 'Hands-on SaaS architecture',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
label: 'MVP architecture and launch',
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
label: 'Fractional CTO for early-stage SaaS',
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
who: {
|
||||
whoForHeading: 'Ideal clients',
|
||||
whoForList: [
|
||||
'Founder-led SaaS companies',
|
||||
'Teams with 1–15 engineers',
|
||||
'Startups experiencing growing infrastructure costs',
|
||||
'Companies that feel their infrastructure is more complex than their product requires',
|
||||
],
|
||||
whoNotList: [
|
||||
'Large enterprises with dedicated platform teams',
|
||||
'Companies already operating at significant infrastructure scale',
|
||||
'Organizations seeking fully managed hosting providers',
|
||||
],
|
||||
},
|
||||
scheduleCta: {
|
||||
sectionId: 'final-cta',
|
||||
headingId: 'final-cta-heading',
|
||||
title: 'Infrastructure should support your product—not compete with it',
|
||||
subtitle:
|
||||
'If your infrastructure feels more complicated than your product requires, it may be time to simplify. Stage-aligned infrastructure helps teams focus on building features instead of maintaining unnecessary systems.',
|
||||
bookingLinkTitle: 'Schedule a discovery call',
|
||||
bookingLinkUrl: `${discoveryCallUrl}-schedule-section`,
|
||||
showEmailLink: true,
|
||||
},
|
||||
meta: {
|
||||
title: 'Startup Infrastructure Strategy | mifi Ventures',
|
||||
description:
|
||||
"Infrastructure decisions should match your company's stage. I help early-stage SaaS teams avoid unnecessary cloud complexity and SaaS sprawl while building infrastructure foundations that scale with the product.",
|
||||
jsonLdServiceDescription:
|
||||
'Stage-aligned infrastructure for early-stage SaaS: infrastructure audits, simplification, and strategy so complexity matches company stage. Capital efficiency, reduced sprawl, and foundations that evolve with the product.',
|
||||
},
|
||||
};
|
||||
36
src/lib/data/services/stage-aligned-infrastructure/faq.ts
Normal file
36
src/lib/data/services/stage-aligned-infrastructure/faq.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FAQ for the stage-aligned infrastructure service page.
|
||||
* Single source of truth for both the page content and FAQPage JSON-LD.
|
||||
*/
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: 'What does stage-aligned infrastructure mean?',
|
||||
answer: 'Infrastructure complexity should grow with your company’s stage—not ahead of it. Stage-aligned infrastructure starts simple and intentional, then evolves deliberately when scale, compliance, or team size justify it. The goal is to avoid overbuilding too early while keeping a foundation that can grow.',
|
||||
},
|
||||
{
|
||||
question: 'Are you anti-cloud?',
|
||||
answer: 'No. Cloud and managed services are the right choice for many companies at the right stage. I’m pragmatic: the question is whether your current complexity matches your actual needs. Adopting cloud services deliberately, when they’re justified, is different from inheriting enterprise patterns by default.',
|
||||
},
|
||||
{
|
||||
question: 'Can simple infrastructure really support a growing SaaS product?',
|
||||
answer: 'Yes. Many early-stage products are surprised by how much a straightforward deployment environment can support. Simple systems are easier to maintain, cheaper to run, and leave more time for building product. You add complexity when scale or requirements demand it—not before.',
|
||||
},
|
||||
{
|
||||
question: 'When should startups adopt more advanced cloud architectures?',
|
||||
answer: 'When compliance, team size, availability requirements, or scale justify the cost and operational burden. There’s no single threshold—it’s about aligning infrastructure with real needs. The goal is to adopt advanced tooling intentionally, with clear reasons, rather than by default.',
|
||||
},
|
||||
{
|
||||
question: 'Can you work with our existing infrastructure?',
|
||||
answer: 'Yes. Most engagements focus on auditing and simplifying what you already have—identifying unnecessary complexity, consolidating tooling, and designing clearer deployment patterns. The goal is restoring clarity and intentionality, not rebuilding everything from scratch.',
|
||||
},
|
||||
{
|
||||
question: 'How quickly can infrastructure complexity be reduced?',
|
||||
answer: 'It depends on the current state, but many teams see a clear path within the first engagement. Simplification is often incremental: we prioritize the highest-impact changes first and plan transitions so the team can keep shipping while complexity is reduced.',
|
||||
},
|
||||
];
|
||||
115
src/lib/data/terms-of-service.ts
Normal file
115
src/lib/data/terms-of-service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Terms of Service content for mifi Ventures. Used by /terms-of-service.
|
||||
* Last updated: March 12, 2026. OpenPhone / A2P messaging compliance; data protection positioning.
|
||||
*/
|
||||
|
||||
export interface LegalSection {
|
||||
id: string;
|
||||
heading: string;
|
||||
body: string[];
|
||||
list?: string[];
|
||||
}
|
||||
|
||||
export const termsOfService = {
|
||||
title: 'Terms of Service',
|
||||
lastUpdated: 'March 12, 2026',
|
||||
intro: [
|
||||
'These Terms of Service govern the use of the website operated by mifi Ventures LLC ("mifi Ventures", "we", "our", or "us").',
|
||||
'By accessing or using this website, you agree to these terms. If you do not agree, you should not use this website.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'services',
|
||||
heading: 'Services',
|
||||
body: [
|
||||
'mifi Ventures provides consulting, software development, technical architecture, and related professional services. The information presented on this website is for informational purposes and may be updated or modified at any time.',
|
||||
'Use of the website does not create a client relationship unless explicitly established through a written agreement.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'client-communications',
|
||||
heading: 'Client Communications',
|
||||
body: [
|
||||
'If you contact mifi Ventures through this website, email, phone, or other communication channels, you consent to receiving responses related to your inquiry.',
|
||||
'These communications may include:',
|
||||
],
|
||||
list: ['Email', 'Phone calls', 'SMS or text messages'],
|
||||
},
|
||||
{
|
||||
id: 'client-communications-sms',
|
||||
heading: 'SMS and text messages',
|
||||
body: [
|
||||
'SMS messages are used solely for direct communication with clients or prospective clients. These may include scheduling messages, project communications, or responses to inquiries.',
|
||||
'mifi Ventures does not send unsolicited marketing text messages and does not sell or share phone numbers for marketing purposes.',
|
||||
'Message frequency varies depending on the nature of the communication. Standard messaging and data rates may apply depending on your mobile carrier.',
|
||||
'You may opt out of SMS communications at any time by replying STOP.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'acceptable-use',
|
||||
heading: 'Acceptable Use',
|
||||
body: ['Users of this website agree not to:'],
|
||||
list: [
|
||||
'Use the website for unlawful purposes',
|
||||
'Attempt to gain unauthorized access to systems or data',
|
||||
'Interfere with the operation or security of the website',
|
||||
'Use automated tools to scrape or harvest data from the site',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'intellectual-property',
|
||||
heading: 'Intellectual Property',
|
||||
body: [
|
||||
'All content on this website, including text, graphics, branding, and software, is the property of mifi Ventures LLC unless otherwise stated.',
|
||||
'Content may not be reproduced, distributed, or reused without written permission.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'third-party-services',
|
||||
heading: 'Third-Party Services',
|
||||
body: [
|
||||
'This website may reference or integrate with third-party platforms or services. mifi Ventures is not responsible for the privacy practices or content of external services.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-protection-and-privacy',
|
||||
heading: 'Data Protection and Privacy',
|
||||
body: [
|
||||
'Your use of this website is also subject to our Privacy Policy, which explains what information we collect, how it is used, and your choices. By using this website, you acknowledge that you have reviewed the Privacy Policy.',
|
||||
'mifi Ventures is based in the United States and primarily serves U.S.-based clients. However, if you are located in a region with specific data protection laws (such as the European Economic Area or the United Kingdom), we will handle personal data in accordance with applicable data protection requirements to the extent they apply and as described in the Privacy Policy.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'limitation-of-liability',
|
||||
heading: 'Limitation of Liability',
|
||||
body: [
|
||||
'To the fullest extent permitted by law, mifi Ventures LLC shall not be liable for indirect, incidental, or consequential damages arising from use of this website or related services.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'governing-law',
|
||||
heading: 'Governing Law and Jurisdiction',
|
||||
body: [
|
||||
'These Terms of Service are governed by the laws of the Commonwealth of Massachusetts and applicable federal law of the United States, without regard to conflict of law principles.',
|
||||
'Any disputes arising out of or relating to these terms or your use of this website shall be brought exclusively in the state or federal courts located in Massachusetts, except where applicable data protection laws provide you with mandatory rights to bring claims in another jurisdiction.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
heading: 'Changes to These Terms',
|
||||
body: [
|
||||
'These Terms of Service may be updated periodically. Continued use of the website after updates indicates acceptance of the revised terms.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
heading: 'Contact',
|
||||
body: [
|
||||
'Questions regarding these Terms of Service may be directed to:',
|
||||
'mifi Ventures LLC',
|
||||
'legal@mifi.ventures',
|
||||
'https://mifi.ventures',
|
||||
],
|
||||
},
|
||||
] satisfies LegalSection[],
|
||||
} as const;
|
||||
53
src/lib/seo.ts
Normal file
53
src/lib/seo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
src/lib/types/faq.ts
Normal file
6
src/lib/types/faq.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export type FaqList = FaqItem[];
|
||||
132
src/lib/types/service-page.ts
Normal file
132
src/lib/types/service-page.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared types for service pages and services landing.
|
||||
* Used by content modules and components for localization-ready structure.
|
||||
*/
|
||||
|
||||
export interface ServiceHeroSecondaryCta {
|
||||
href: string;
|
||||
label: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
export interface ServiceHeroContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl: string;
|
||||
secondaryCta?: ServiceHeroSecondaryCta;
|
||||
}
|
||||
|
||||
export interface ServiceTocItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ServiceFooterLink {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ServiceSectionContent {
|
||||
id: string;
|
||||
heading: string;
|
||||
headingId?: string;
|
||||
/** If true, heading is visually hidden (sr-only) for accessibility */
|
||||
headingSrOnly?: boolean;
|
||||
/** Extra class(es) for the section element (e.g. service-credibility) */
|
||||
sectionClass?: string;
|
||||
/** Use narrow; set false for full-width strips (e.g. credibility) */
|
||||
narrowContainer?: boolean;
|
||||
/** Optional class for the bullets ul (e.g. service-credibility__list) */
|
||||
bulletsListClass?: string;
|
||||
lede?: string;
|
||||
paragraphs?: string[];
|
||||
bullets?: string[];
|
||||
orderedBullets?: string[];
|
||||
/** Paragraph(s) rendered after bullets (e.g. "Speed isn't the problem. Structure is.") */
|
||||
trailingParagraphs?: string[];
|
||||
/** Inline links (e.g. cross-links to other services) rendered after content */
|
||||
footerLinks?: ServiceFooterLink[];
|
||||
/** Optional sub-sections: h3 + content (paragraphs and/or bullets) */
|
||||
subsections?: Array<{
|
||||
heading: string;
|
||||
headingId?: string;
|
||||
paragraphs?: string[];
|
||||
bullets?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WhoGridContent {
|
||||
whoForList: string[];
|
||||
whoNotList: string[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
whoForHeading?: string;
|
||||
whoNotHeading?: string;
|
||||
}
|
||||
|
||||
export interface ServiceScheduleCta {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl: string;
|
||||
showEmailLink?: boolean;
|
||||
showServicesLink?: boolean;
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
}
|
||||
|
||||
/** Nav item for service/landing page navigation */
|
||||
export interface ServiceNavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
/** Service card for the services landing grid */
|
||||
export interface ServiceCard {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** Term/definition for "How engagements work" dl on landing */
|
||||
export interface EngagementItem {
|
||||
term: string;
|
||||
definition: string;
|
||||
}
|
||||
|
||||
/** SEO meta for use in +page.ts (single source of truth with content) */
|
||||
export interface ServicePageMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional short description for JSON-LD ProfessionalService */
|
||||
jsonLdServiceDescription?: string;
|
||||
}
|
||||
|
||||
/** Content shape for the services landing page */
|
||||
export interface ServicesLandingContent {
|
||||
hero: ServiceHeroContent;
|
||||
navItems: ServiceNavItem[];
|
||||
introParagraphs: string[];
|
||||
services: ServiceCard[];
|
||||
engagements: EngagementItem[];
|
||||
engagementsIntro?: string;
|
||||
engagementsOutro?: string;
|
||||
idealClients: string[];
|
||||
scheduleCta: ServiceScheduleCta;
|
||||
meta: ServicePageMeta;
|
||||
}
|
||||
|
||||
/** Content shape for a service detail page */
|
||||
export interface ServiceDetailContent {
|
||||
hero: ServiceHeroContent;
|
||||
tocItems: ServiceTocItem[];
|
||||
navItems: ServiceNavItem[];
|
||||
sections: ServiceSectionContent[];
|
||||
who: WhoGridContent;
|
||||
scheduleCta: ServiceScheduleCta;
|
||||
/** Optional FAQ section title override */
|
||||
faqTitle?: string;
|
||||
meta: ServicePageMeta;
|
||||
}
|
||||
270
src/routes/+layout.svelte
Normal file
270
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { mergeMeta, SEO_DEFAULTS } from '$lib/seo';
|
||||
import { homeMeta } from '$lib/data/home/home-meta';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
|
||||
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
|
||||
? '<scr' + 'ipt type="application/ld+json">' + jsonLdScript + '</scr' + 'ipt>'
|
||||
: '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.mifi.holdings/script.js"
|
||||
data-website-id="72ac01ce-e7fc-4582-8593-703f15add8d5"
|
||||
></script>
|
||||
<script defer src="/assets/js/umami-helper.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/inter-v20-latin-regular.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>
|
||||
<script src="/assets/js/cookie-consent.js" defer></script>
|
||||
</svelte:head>
|
||||
|
||||
<a href="#main" class="skip-link" data-umami-event="skip to main content"
|
||||
>Skip to main content</a
|
||||
>
|
||||
{@render children()}
|
||||
<Footer />
|
||||
<div id="cookie-banner" class="cookie-banner" role="region" aria-label="Cookie consent">
|
||||
<div class="cookie-banner-content">
|
||||
<p class="cookie-notification-text">
|
||||
We use first-party analytics and, if you accept, third-party tools (e.g.
|
||||
Google, Microsoft) to understand usage and improve this site. You can accept
|
||||
all or reject non-essential analytics.
|
||||
<a href="/privacy-policy#analytics-and-tracking">Learn more</a>.
|
||||
</p>
|
||||
<div class="cookie-banner-actions">
|
||||
<button type="button" class="btn btn-primary small" data-consent="accept"
|
||||
>Accept all</button
|
||||
>
|
||||
<button type="button" class="btn btn-secondary small" data-consent="reject"
|
||||
>Reject non-essential</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
|
||||
alt=""
|
||||
width="1"
|
||||
height="1"
|
||||
role="presentation"
|
||||
loading="eager"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
background-color: var(--color-bg-elevated, var(--color-bg));
|
||||
color: var(--color-text);
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.16);
|
||||
|
||||
&:global(.is-visible) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-banner-content {
|
||||
container: cookie-banner / inline-size;
|
||||
max-width: var(--max-narrow-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-md) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-notification-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-base);
|
||||
|
||||
@container cookie-banner (width >= 644px) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@container cookie-banner (width < 644px) {
|
||||
flex: 0 0 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.16em;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-banner-actions {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: flex-start;
|
||||
|
||||
@container cookie-banner (width >= 644px) {
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@container cookie-banner (width < 644px) {
|
||||
flex: 0 0 100%;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
& .btn {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& [data-consent='accept'] {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
& [data-consent='reject'] {
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-subtle, rgba(148, 163, 184, 0.16));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/routes/+layout.ts
Normal file
3
src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const prerender = true;
|
||||
export const ssr = true;
|
||||
export const csr = false;
|
||||
31
src/routes/+page.svelte
Normal file
31
src/routes/+page.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import HomeHero from '$lib/components/home/Hero.svelte';
|
||||
import ExperienceSection from '$lib/components/home/ExperienceSection.svelte';
|
||||
import WhatWeDo from '$lib/components/home/WhatWeDo.svelte';
|
||||
import ImpactSection from '$lib/components/home/ImpactSection.svelte';
|
||||
import HowWeWork from '$lib/components/home/HowWeWork.svelte';
|
||||
import EngagementsSection from '$lib/components/home/EngagementsSection.svelte';
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
import { items as navigationItems } from '$lib/data/home/navigation';
|
||||
import ServicesCardGrid from '$lib/components/ServicesCardGrid.svelte';
|
||||
import { services } from '$lib/data/home/services';
|
||||
</script>
|
||||
|
||||
<Navigation items={navigationItems} page="home" />
|
||||
<HomeHero />
|
||||
<main id="main">
|
||||
<ServicesCardGrid {services} surface="bg-alt" />
|
||||
<ExperienceSection />
|
||||
<WhatWeDo />
|
||||
<ImpactSection />
|
||||
<HowWeWork />
|
||||
<EngagementsSection />
|
||||
<ScheduleSection
|
||||
title="Need a technical adult in the room?"
|
||||
subtitle="Whether you need help launching an MVP, stabilizing a messy product foundation, or adding senior technical judgment as complexity grows, I can help."
|
||||
bookingLinkTitle="Schedule a 30-minute intro call"
|
||||
bookingLinkUrl="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=home_page-schedule-section"
|
||||
showServicesLink
|
||||
/>
|
||||
</main>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user