Compare commits

..

43 Commits

Author SHA1 Message Date
66640fa535 Add scroll depth tracking
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 13:00:59 -03:00
5e0e211f80 Wrong pixel URI
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:47:42 -03:00
d66e9f7cb8 Update link tracking classifications
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:42:39 -03:00
39ba54e254 Add tracking to skip link
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:27:03 -03:00
56b5740393 Update snapshots
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:22:30 -03:00
3a94a50def Add tracking
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-17 11:47:35 -03:00
1349488827 Setup umami analytics
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 11:05:29 -03:00
c963e34766 Proper 410/404 pages
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 01:18:38 -03:00
f91531b5fa Even stupider typo... I'm tired.
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:37:45 -03:00
b0146992c2 Silly secret typo
Some checks failed
ci/woodpecker/push/deploy unknown status
ci/woodpecker/push/ci Pipeline failed
2026-02-12 01:36:48 -03:00
7a01cbd2c9 Final notifications setup 2026-02-12 01:35:22 -03:00
fe8cf26a29 Channel ID typo?
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:22:39 -03:00
a519df1016 Stupid typo
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 01:14:01 -03:00
ceeb76663b Test with posts API and bot
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 01:12:10 -03:00
14edd403eb Let the bot be the notifier and fix for missing svelte kit in unit tests
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 00:48:52 -03:00
9f43bf7879 One last tweak
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 00:35:57 -03:00
2d0a4935a5 Pipeline fixes
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
2026-02-12 00:32:52 -03:00
e4929a4699 Trigger pipelines
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-12 00:17:00 -03:00
af705efc17 Pipeline fixes
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-02-11 23:43:14 -03:00
f73b7822a5 Pipeline Notification Updates (swap Discord to Mattermost) 2026-02-11 23:40:39 -03:00
c094cc29ea TJX Logo removal (legal request) (#5) (#6)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
Remove logo at request of TJX Companies

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

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

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

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# CI workflow: one clone, one workspace — install → lint → build → test.
# 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:
@@ -19,17 +19,133 @@ steps:
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: test
image: node:20-alpine
- 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 test
- 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]

View File

@@ -2,8 +2,10 @@
# 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]
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
@@ -12,6 +14,7 @@ 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
@@ -29,9 +32,46 @@ steps:
.
- 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:
@@ -55,6 +95,42 @@ steps:
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:
@@ -74,3 +150,39 @@ steps:
echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on:
- 'Push to registry'
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- 'Trigger Portainer stack redeploy'
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

View File

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

75
AGENTS.md Normal file
View File

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

View File

@@ -32,6 +32,8 @@ This project uses **pnpm** as the package manager. After cloning, run `pnpm inst
| `pnpm run build` | SvelteKit build → `dist/`, then Critters inlines critical CSS |
| `pnpm run preview` | Serve `dist/` (Critters-processed) at http://localhost:4173 — same as deployed |
| `pnpm test` | Run unit tests (Vitest) |
| `pnpm run test:e2e` | Run Playwright visual regression (e2e) |
| `pnpm run test:e2e:update-snapshots` | Regenerate e2e snapshots (in devcontainer = CI; see Visual regression) |
| `pnpm run lint` | ESLint (JS/TS/Svelte) |
| `pnpm run lint:css` | Stylelint (global CSS + Svelte styles) |
| `pnpm run format` | Prettier (JS/TS/Svelte/CSS/JSON) |
@@ -59,11 +61,11 @@ Preview uses `serve dist` so you see the same HTML/CSS as in production (includi
### 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:
@@ -72,7 +74,21 @@ pnpm install
pnpm run dev
```
The site is served at **http://localhost:5173** (or the port shown) with live reload (port forwarded automatically).
The site is served at **http://localhost:5173** (or the port shown) with live reload (port forwarded automatically). Run `pnpm run test:e2e:update-snapshots` here to regenerate the Linux snapshot when the layout changes.
### Visual regression (e2e)
E2e tests use Playwright and compare full-page screenshots to committed snapshots. CI and the **devcontainer** both use `mcr.microsoft.com/playwright:v1.58.0-noble`, so snapshots generated in the devcontainer match CI.
**To update the Linux snapshot locally (devcontainer):**
1. Rebuild the devcontainer once (so it uses the Playwright Noble image; see Option 3 below).
2. Run `pnpm run test:e2e:update-snapshots` (no Docker needed — same environment as CI).
3. Commit the updated file(s) under `tests/visual.spec.ts-snapshots/`.
**If youre not using the devcontainer:** run the **update-e2e-snapshots** workflow manually in Woodpecker (requires a `git_push_token` secret), or run `pnpm run test:e2e:update-snapshots` on a host with Docker.
Local `pnpm run test:e2e` on **macOS** uses the Darwin snapshot; the Linux snapshot is used in CI and in the devcontainer.
### Option 4: Docker (Production-like Test)
@@ -162,6 +178,7 @@ Navigate to your repository → Settings → Secrets and add:
| `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:**

View File

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

View File

@@ -5,41 +5,42 @@ 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'
]
},
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'
}
}
{
ignores: [
'.svelte-kit/**',
'dist/**',
'build/**',
'node_modules/**',
'site/**',
'static/**',
'build.mjs',
'playwright-report/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
projectService: true,
extraFileExtensions: ['.svelte'],
svelteConfig,
},
},
},
{
files: ['**/*.mjs', 'build.mjs'],
languageOptions: { globals: { console: 'readonly', process: 'readonly' } },
},
{
rules: {
'svelte/no-at-html-tags': 'warn',
'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off',
},
},
];

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -3,11 +3,11 @@
"version": "2.0.0",
"private": true,
"repository": "https://git.mifi.dev/mifi-ventures/landing.git",
"packageManager": "pnpm@10.28.2",
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
"type": "module",
"scripts": {
"build": "vite build && node scripts/critters.mjs",
"build": "vite build && node scripts/critters.mjs && node scripts/minify-static-js.mjs && node scripts/copy-410-paths.mjs",
"dev": "vite dev",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -20,6 +20,7 @@
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:update-snapshots": "bash scripts/update-e2e-snapshots.sh",
"test:all": "vitest run && playwright test",
"test:watch": "vitest"
},
@@ -29,10 +30,13 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/gtag.js": "^0.0.20",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"critters": "^0.0.24",
"esbuild": "^0.24.0",
"eslint": "^9.39.2",
"jsdom": "^25.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"postcss-html": "^1.8.1",
@@ -50,5 +54,8 @@
"typescript-eslint": "^8.54.0",
"vite": "^6.0.0",
"vitest": "^4.0.18"
},
"dependencies": {
"@lucide/svelte": "^0.563.1"
}
}

View File

@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
testMatch: /.*\.spec\.(ts|js)/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,

748
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

@@ -0,0 +1,43 @@
#!/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
'
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."

View File

@@ -375,6 +375,13 @@ a {
}
}
.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. */

View File

@@ -8,12 +8,10 @@
aria-labelledby="experience-heading"
>
<div class="container">
<h2 id="experience-heading" class="section-title">
Experience includes teams at:
</h2>
<h2 id="experience-heading" class="section-title">Previously at:</h2>
<div class="logo-strip" role="list" aria-label="Company logos">
{#each experienceLogos as logo (logo.alt)}
{#each experienceLogos.filter((logo) => logo.showLogo) as logo (logo.alt)}
<div class="logo-item" role="listitem">
<img
src={logo.src}

View File

@@ -1,3 +1,9 @@
<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">
@@ -5,17 +11,31 @@
</p>
<nav class="footer-links" aria-label="Social media links">
<a
class="link"
href="https://linkedin.com/in/the-mifi"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile (opens in new tab)">LinkedIn</a
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)">GitHub</a
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>
</nav>
</div>
</footer>
@@ -35,4 +55,11 @@
color: var(--color-text-tertiary);
max-width: 100%;
}
.link {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
</style>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
import FiletypePdfIcon from './Icon/FiletypePdf.svelte';
import Logo from './Logo.svelte';
</script>
@@ -13,20 +15,26 @@
<div class="cta-group">
<a
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=hero_cta"
class="btn btn-primary"
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 section"
>
Schedule a 30-minute intro call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
<a
href="/downloads/resume.pdf"
class="btn btn-secondary"
class="btn btn-secondary icon-button"
download
aria-label="Download Mike Fitzpatrick's resume as PDF"
data-umami-event="download resume"
data-umami-event-location="hero section"
>
Download resume
<FiletypePdfIcon aria-label="PDF format file" size={17} />
</a>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,34 +14,73 @@
<span class="mobile nav-header-logo">
<Wordmark />
</span>
<label for="nav-toggle" class="nav-toggle" aria-label="Toggle navigation">
<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
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 class="nav-menu container">
<div id="nav-menu" class="nav-menu container">
<span class="nav-header-logo desktop">
<Wordmark />
</span>
<ul class="nav-list">
<li class="nav-item">
<a href="#what-we-do" class="nav-link">Services</a>
<a
href="#what-we-do"
class="nav-link"
data-umami-event="navigation"
data-umami-event-label="services">Services</a
>
</li>
<li class="nav-item">
<a href="#impact" class="nav-link">Impact</a>
<a
href="#impact"
class="nav-link"
data-umami-event="navigation"
data-umami-event-label="impact">Impact</a
>
</li>
<li class="nav-item">
<a href="#how-we-work" class="nav-link">Process</a>
<a
href="#how-we-work"
class="nav-link"
data-umami-event="navigation"
data-umami-event-label="process">Process</a
>
</li>
<li class="nav-item">
<a href="#schedule" class="nav-link">Contact</a>
<a
href="#schedule"
class="nav-link"
data-umami-event="navigation"
data-umami-event-label="contact">Contact</a
>
</li>
</ul>
<div class="nav-item nav-back-to-top">
<a href="#header" class="nav-link">Back to top</a>
<a
href="#header"
class="nav-link"
data-umami-event="navigation"
data-umami-event-label="back to top">Back to top</a
>
</div>
</div>
</nav>
@@ -51,17 +90,17 @@
background-color: var(--color-bg);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
padding: var(--space-md) 0;
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;
align-items: stretch;
padding: var(--space-md) 0;
}
}
@@ -142,6 +181,14 @@
}
}
.btn-clear {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
}
.nav-toggle {
display: none;
align-items: center;
@@ -281,28 +328,56 @@
}
@supports (animation-timeline: scroll()) {
/* Shadow on pseudo-element; only opacity is animated (composited) */
.nav::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
pointer-events: none;
opacity: 0;
animation: nav-shadow-on-scroll linear;
animation-timeline: scroll(root block);
animation-range: 0 100px;
animation-fill-mode: both;
will-change: opacity;
}
@keyframes nav-shadow-on-scroll {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.nav::after {
animation: none;
opacity: 1;
}
}
/* Composited-only animation: opacity only (visibility/pointer-events not animated) */
.nav-back-to-top,
.nav-header-logo {
opacity: 0;
visibility: hidden;
pointer-events: none;
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;
visibility: hidden;
pointer-events: none;
}
to {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}

View File

@@ -1,3 +1,7 @@
<script lang="ts">
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
</script>
<section
id="schedule"
class="section schedule-section"
@@ -8,12 +12,15 @@
<p class="schedule-text">Ready to discuss your project?</p>
<a
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=schedule_section_cta"
class="btn btn-primary"
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="schedule section"
>
Schedule a 30-minute intro call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
</div>
</section>
@@ -32,4 +39,8 @@
line-height: var(--line-height-relaxed);
max-width: 100%;
}
.btn {
margin: 0 auto;
}
</style>

View File

@@ -1,25 +1,52 @@
export const experienceLogos = [
{ src: '/assets/logos/atlassian.svg', alt: 'Atlassian', width: 2500, height: 2500 },
{
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/cargurus.svg', alt: 'CarGurus', width: 2500, height: 398 },
{ src: '/assets/logos/timberland.svg', alt: 'Timberland', width: 190, height: 35 },
{ src: '/assets/logos/vf.svg', alt: 'VF Corporation', width: 190, height: 155 },
{
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;

View File

@@ -30,6 +30,12 @@
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-36F29PDKRT"></script>
<script defer src="/assets/js/ga-init.js"></script>
<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 ?? ''} />
@@ -132,15 +138,26 @@
<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" />
<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/scripts/copyright-year.js" defer></script>
<script src="/assets/js/copyright-year.js" defer></script>
<script src="/assets/js/mobile-menu-helper.js" defer></script>
</svelte:head>
<a href="#main" class="skip-link">Skip to main content</a>
<a href="#main" class="skip-link" data-umami-event="skip to main content"
>Skip to main content</a
>
{@render children()}
<img
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
alt=""
width="1"
height="1"
role="presentation"
loading="eager"
/>

View File

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

20
static/404.html Normal file
View File

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

20
static/410.html Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

@@ -0,0 +1,28 @@
// Umami: safe track (no-op if script blocked or not loaded)
function umamiTrack(name, data) {
if (
typeof window.umami !== 'undefined' &&
typeof window.umami.track === 'function'
) {
if (data != null) window.umami.track(name, data);
else window.umami.track(name);
}
}
// Umami: scroll depth (25%, 50%, 75%, 100%) once per milestone
const scrollMilestones = new Set();
function onScroll() {
const doc = document.documentElement;
const scrollTop = doc.scrollTop || document.body.scrollTop;
const scrollHeight =
(doc.scrollHeight || document.body.scrollHeight) - window.innerHeight;
if (scrollHeight <= 0) return;
const pct = Math.round((scrollTop / scrollHeight) * 100);
for (const m of [25, 50, 75, 100]) {
if (pct >= m && !scrollMilestones.has(m)) {
scrollMilestones.add(m);
umamiTrack('scroll-depth', { depth: String(m) });
}
}
}
window.addEventListener('scroll', onScroll, { passive: true });

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,15 +1,9 @@
<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 width="100%" height="100%" viewBox="0 0 512 512" 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>
.block { fill: #0b0b0f; }
@media (prefers-color-scheme: dark) {
.block { fill: #f2f2f2; }
}
</style>
<path class="block" d="M512,96l0,320c0,52.984 -43.016,96 -96,96l-320,0c-52.984,0 -96,-43.016 -96,-96l0,-320c0,-52.984 43.016,-96 96,-96l320,0c52.984,0 96,43.016 96,96Zm-96.011,80.389l53.127,0l0,-56.669l-53.127,0l0,56.669Zm-193.658,55.292c-4.819,-8.296 -11.558,-15.376 -20.217,-21.24c-13.796,-9.344 -29.667,-14.015 -47.612,-14.015c-16.461,0 -30.814,3.913 -43.058,11.739c-7.882,5.038 -14.038,11.753 -18.468,20.146l0,-27.027l-50.091,0l0,219.997l53.127,0l0,-129.124c0,-9.715 1.771,-18.063 5.313,-25.046c3.542,-6.982 8.517,-12.413 14.926,-16.292c6.409,-3.879 13.864,-5.819 22.364,-5.819c8.703,0 16.191,1.94 22.465,5.819c6.274,3.879 11.165,9.293 14.673,16.242c3.508,6.949 5.262,15.314 5.262,25.096l0,129.124l53.127,0l0,-129.124c0,-9.715 1.771,-18.063 5.313,-25.046c3.542,-6.982 8.534,-12.413 14.977,-16.292c6.443,-3.879 13.881,-5.819 22.313,-5.819c8.703,0 16.191,1.94 22.465,5.819c6.274,3.879 11.165,9.293 14.673,16.242c3.508,6.949 5.262,15.314 5.262,25.096l0,129.124l53.127,0l0,-141.774c0,-16.394 -3.559,-30.831 -10.676,-43.311c-7.117,-12.481 -16.815,-22.229 -29.093,-29.245c-12.278,-7.016 -26.209,-10.524 -41.793,-10.524c-17.608,0 -33.141,4.335 -46.6,13.004c-8.624,5.555 -15.884,12.972 -21.779,22.252Zm193.658,189.599l53.127,0l0,-219.997l-53.127,0l0,219.997Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -9,8 +9,8 @@ Allow: /
# Disallow specific paths if needed
# Disallow: /private/
# Sitemap (add when available)
# Sitemap: https://mifi.ventures/sitemap.xml
# Sitemap
Sitemap: https://mifi.ventures/sitemap.xml
# Host preference (helps search engines understand the canonical domain)
# Host: https://mifi.ventures

9
static/sitemap.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mifi.ventures/</loc>
<lastmod>2025-02-02</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -0,0 +1,44 @@
/**
* @vitest-environment jsdom
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = join(__dirname, '../../static/assets/js/copyright-year.js');
describe('copyright-year.js', () => {
let el: HTMLSpanElement;
beforeEach(() => {
el = document.createElement('span');
el.id = 'copyright-year';
el.textContent = 'placeholder';
document.body.appendChild(el);
});
afterEach(() => {
el.remove();
});
it('sets #copyright-year textContent to current year when element exists', () => {
const code = readFileSync(SCRIPT_PATH, 'utf8');
eval(code);
const expected = new Date().getFullYear().toString();
expect(el.textContent).toBe(expected);
});
it('does not throw when #copyright-year is missing', () => {
el.remove();
const code = readFileSync(SCRIPT_PATH, 'utf8');
expect(() => {
eval(code);
}).not.toThrow();
});
});

View File

@@ -0,0 +1,148 @@
/**
* @vitest-environment jsdom
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = join(__dirname, '../../static/assets/js/mobile-menu-helper.js');
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
value: width,
writable: true,
configurable: true,
});
}
function createNavDOM() {
const nav = document.createElement('nav');
nav.id = 'nav';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'nav-toggle';
checkbox.className = 'nav-toggle-input';
nav.appendChild(checkbox);
const header = document.createElement('div');
header.className = 'mobile-nav-header';
const toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.id = 'nav-toggle-button';
toggleButton.setAttribute('aria-controls', 'nav-menu');
toggleButton.setAttribute('aria-expanded', 'false');
const label = document.createElement('label');
label.id = 'nav-toggle-label';
label.htmlFor = 'nav-toggle';
label.className = 'nav-toggle';
toggleButton.appendChild(label);
header.appendChild(toggleButton);
nav.appendChild(header);
const menu = document.createElement('div');
menu.id = 'nav-menu';
menu.className = 'nav-menu container';
const list = document.createElement('ul');
list.className = 'nav-list';
const item = document.createElement('li');
item.className = 'nav-item';
const link = document.createElement('a');
link.href = '#test';
link.className = 'nav-link';
link.textContent = 'Test';
item.appendChild(link);
list.appendChild(item);
menu.appendChild(list);
nav.appendChild(menu);
document.body.appendChild(nav);
return { nav, menu, toggleButton, label, checkbox, item, link };
}
describe('mobile-menu-helper.js', () => {
let dom: ReturnType<typeof createNavDOM>;
beforeEach(() => {
setViewportWidth(400); // mobile
dom = createNavDOM();
const code = readFileSync(SCRIPT_PATH, 'utf8');
eval(code);
document.dispatchEvent(new Event('DOMContentLoaded'));
});
afterEach(() => {
dom.nav.remove();
vi.restoreAllMocks();
});
it('sets aria-hidden on menu when viewport is mobile and menu is closed', () => {
expect(dom.menu.getAttribute('aria-hidden')).toBe('true');
expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('false');
});
it('sets inert and tabindex="-1" on links when menu is closed on mobile', () => {
expect(dom.menu.hasAttribute('inert')).toBe(true);
expect(dom.link.getAttribute('tabindex')).toBe('-1');
});
it('sets aria-hidden to false when menu is open on mobile', () => {
dom.checkbox.checked = true;
dom.checkbox.dispatchEvent(new Event('change', { bubbles: true }));
expect(dom.menu.getAttribute('aria-hidden')).toBe('false');
expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('true');
});
it('removes inert and link tabindex when menu is open on mobile', () => {
dom.checkbox.checked = true;
dom.checkbox.dispatchEvent(new Event('change', { bubbles: true }));
expect(dom.menu.hasAttribute('inert')).toBe(false);
expect(dom.link.hasAttribute('tabindex')).toBe(false);
});
it('removes aria-hidden and inert from menu when viewport is desktop', () => {
setViewportWidth(1024);
window.dispatchEvent(new Event('resize'));
expect(dom.menu.hasAttribute('aria-hidden')).toBe(false);
expect(dom.menu.hasAttribute('inert')).toBe(false);
expect(dom.link.hasAttribute('tabindex')).toBe(false);
});
it('adds aria-hidden and inert when resizing from desktop to mobile', () => {
setViewportWidth(1024);
window.dispatchEvent(new Event('resize'));
expect(dom.menu.hasAttribute('aria-hidden')).toBe(false);
expect(dom.menu.hasAttribute('inert')).toBe(false);
setViewportWidth(400);
window.dispatchEvent(new Event('resize'));
expect(dom.menu.getAttribute('aria-hidden')).toBe('true');
expect(dom.menu.hasAttribute('inert')).toBe(true);
expect(dom.link.getAttribute('tabindex')).toBe('-1');
});
it('closes menu and syncs aria when a menu item is clicked', () => {
dom.checkbox.checked = true;
dom.checkbox.dispatchEvent(new Event('change', { bubbles: true }));
expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('true');
dom.item.click();
expect(dom.checkbox.checked).toBe(false);
expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('false');
expect(dom.menu.getAttribute('aria-hidden')).toBe('true');
expect(dom.menu.hasAttribute('inert')).toBe(true);
expect(dom.link.getAttribute('tabindex')).toBe('-1');
});
// Keyboard open (Enter/Space on toggle button) is not asserted here: jsdoms KeyboardEvent
// often does not set e.key, so the keydown handler may not run. Opening and sync are covered
// by “sets aria-hidden to false when menu is open” and “removes inert and link tabindex when
// menu is open”; keyboard open can be covered in e2e.
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

View File

@@ -11,6 +11,6 @@
"strict": true,
"moduleResolution": "bundler"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "static/assets/js/copyright-year.ts"],
"exclude": ["node_modules"]
}

View File

@@ -5,6 +5,12 @@ export default defineConfig({
plugins: [sveltekit()],
server: {
host: true, // listen on 0.0.0.0 so reachable from host (e.g. dev container, port forward)
port: 5173,
strictPort: false,
// Disable HMR WebSocket when using port forwarding (e.g. dev container); the tunnel
// often doesn't proxy WebSockets, causing repeated connection failures in the console.
// With csr: false we don't use client-side HMR anyway—refresh the page to see changes.
hmr: false,
},
preview: {
host: true,