Compare commits
14 Commits
main
...
12fb8c0335
| Author | SHA1 | Date | |
|---|---|---|---|
|
12fb8c0335
|
|||
|
915d53fdba
|
|||
|
9bf06d8cb0
|
|||
|
45e9382645
|
|||
|
e7c61152d4
|
|||
|
a508d1537e
|
|||
|
3d5f79ba08
|
|||
|
3ffb9de80a
|
|||
|
00afa9491f
|
|||
|
192d7d0c86
|
|||
|
fe8f7cb7c0
|
|||
|
414cf5c4ce
|
|||
|
c5c160d259
|
|||
|
bc32528551
|
@@ -1,11 +1,18 @@
|
||||
# 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
|
||||
# Dev container for mifi Ventures static site
|
||||
# Lightweight: Node for static server (npx serve), no app dependencies
|
||||
|
||||
# pnpm for this project (CI uses the same)
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
|
||||
|
||||
# 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
|
||||
|
||||
# Default user is root (Playwright image); devcontainer runs as root for e2e.
|
||||
# npx serve is used at runtime via postStartCommand
|
||||
# No npm install needed — static site, no package.json
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"4173": {
|
||||
"label": "Preview (Vite)",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
"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"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
"4173": {
|
||||
"label": "Preview (Vite)",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-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"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CI workflow: one clone, one workspace — install → lint → build → unit test - e2e test.
|
||||
# CI workflow: one clone, one workspace — install → lint → build → 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,133 +19,17 @@ 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: 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
|
||||
- name: test
|
||||
image: node:20-alpine
|
||||
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]
|
||||
- pnpm test
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
# 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"'
|
||||
branch: main
|
||||
event: [push, tag, manual]
|
||||
|
||||
depends_on:
|
||||
- ci
|
||||
@@ -14,7 +12,6 @@ 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
|
||||
@@ -32,46 +29,9 @@ 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:
|
||||
@@ -95,42 +55,6 @@ 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:
|
||||
@@ -150,39 +74,3 @@ 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]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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
@@ -1,75 +0,0 @@
|
||||
# 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`).
|
||||
23
README.md
@@ -32,8 +32,6 @@ 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) |
|
||||
@@ -61,11 +59,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. The devcontainer uses the **same image as CI** (`mcr.microsoft.com/playwright:v1.58.0-noble`), so e2e snapshots generated inside it match CI.
|
||||
Open the project in a dev container for a consistent local environment:
|
||||
|
||||
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. (If you already had a devcontainer open, use **Rebuild Container** once to pick up the Playwright Noble image.)
|
||||
3. Wait for the container to build and start.
|
||||
|
||||
**Inside the container**, run:
|
||||
|
||||
@@ -74,21 +72,7 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Local `pnpm run test:e2e` on **macOS** uses the Darwin snapshot; the Linux snapshot is used in CI and in the devcontainer.
|
||||
The site is served at **http://localhost:5173** (or the port shown) with live reload (port forwarded automatically).
|
||||
|
||||
### Option 4: Docker (Production-like Test)
|
||||
|
||||
@@ -178,7 +162,6 @@ 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:**
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ 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
|
||||
@@ -21,7 +20,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=security-supermax-with-analytics@file,redirect-www-to-non-www@file"
|
||||
- "traefik.http.routers.mifiventures.middlewares=secure-all@file,redirect-www-to-non-www@file"
|
||||
- "traefik.http.routers.mifiventures.tls=true"
|
||||
- "traefik.http.routers.mifiventures.tls.certresolver=letsencrypt"
|
||||
networks:
|
||||
|
||||
@@ -5,42 +5,41 @@ 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',
|
||||
},
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
BIN
logos/avatar.af
BIN
logos/favicon.af
34
nginx.conf
@@ -108,42 +108,14 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 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.)
|
||||
# Deny access to hidden files (.git, .env, etc.)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# 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";
|
||||
}
|
||||
# 404 falls back to index.html for SPA-style routing
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
@@ -3,11 +3,11 @@
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"repository": "https://git.mifi.dev/mifi-ventures/landing.git",
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && node scripts/critters.mjs && node scripts/minify-static-js.mjs && node scripts/copy-410-paths.mjs",
|
||||
"build": "vite build && node scripts/critters.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,7 +20,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -30,13 +29,10 @@
|
||||
"@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",
|
||||
@@ -54,8 +50,5 @@
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^0.563.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
@@ -1,39 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/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."
|
||||
@@ -375,13 +375,6 @@ 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. */
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
aria-labelledby="experience-heading"
|
||||
>
|
||||
<div class="container">
|
||||
<h2 id="experience-heading" class="section-title">Previously at:</h2>
|
||||
<h2 id="experience-heading" class="section-title">
|
||||
Experience includes teams at:
|
||||
</h2>
|
||||
|
||||
<div class="logo-strip" role="list" aria-label="Company logos">
|
||||
{#each experienceLogos.filter((logo) => logo.showLogo) as logo (logo.alt)}
|
||||
{#each experienceLogos as logo (logo.alt)}
|
||||
<div class="logo-item" role="listitem">
|
||||
<img
|
||||
src={logo.src}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
<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">
|
||||
@@ -11,31 +5,17 @@
|
||||
</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)"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="linkedin"
|
||||
aria-label="LinkedIn profile (opens in new tab)">LinkedIn</a
|
||||
>
|
||||
<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"
|
||||
aria-label="GitHub profile (opens in new tab)">GitHub</a
|
||||
>
|
||||
<GithubIcon size={15} />
|
||||
GitHub
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={15} />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -55,11 +35,4 @@
|
||||
color: var(--color-text-tertiary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
|
||||
import FiletypePdfIcon from './Icon/FiletypePdf.svelte';
|
||||
import Logo from './Logo.svelte';
|
||||
</script>
|
||||
|
||||
@@ -15,26 +13,20 @@
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=hero_cta"
|
||||
class="btn btn-primary icon-button"
|
||||
class="btn btn-primary"
|
||||
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 icon-button"
|
||||
class="btn btn-secondary"
|
||||
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>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -14,73 +14,34 @@
|
||||
<span class="mobile nav-header-logo">
|
||||
<Wordmark />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="nav-toggle-button"
|
||||
class="btn-clear"
|
||||
aria-controls="nav-menu"
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<label
|
||||
id="nav-toggle-label"
|
||||
for="nav-toggle"
|
||||
class="nav-toggle"
|
||||
role="presentation"
|
||||
>
|
||||
<span class="nav-toggle-inner">
|
||||
<span class="nav-toggle-line"></span>
|
||||
<span class="nav-toggle-line"></span>
|
||||
<span class="nav-toggle-line"></span>
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<div id="nav-menu" class="nav-menu container">
|
||||
<div 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"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="services">Services</a
|
||||
>
|
||||
<a href="#what-we-do" class="nav-link">Services</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#impact"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="impact">Impact</a
|
||||
>
|
||||
<a href="#impact" class="nav-link">Impact</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#how-we-work"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="process">Process</a
|
||||
>
|
||||
<a href="#how-we-work" class="nav-link">Process</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#schedule"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="contact">Contact</a
|
||||
>
|
||||
<a href="#schedule" class="nav-link">Contact</a>
|
||||
</li>
|
||||
</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
|
||||
>
|
||||
<a href="#header" class="nav-link">Back to top</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -90,17 +51,17 @@
|
||||
background-color: var(--color-bg);
|
||||
background-color: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-top: var(--space-sm);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||
padding: var(--space-md) 0;
|
||||
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;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,14 +142,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
@@ -328,56 +281,28 @@
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="schedule"
|
||||
class="section schedule-section"
|
||||
@@ -12,15 +8,12 @@
|
||||
<p class="schedule-text">Ready to discuss your project?</p>
|
||||
<a
|
||||
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=schedule_section_cta"
|
||||
class="btn btn-primary icon-button"
|
||||
class="btn btn-primary"
|
||||
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>
|
||||
@@ -39,8 +32,4 @@
|
||||
line-height: var(--line-height-relaxed);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,25 @@
|
||||
export const experienceLogos = [
|
||||
{
|
||||
src: '/assets/logos/atlassian.svg',
|
||||
alt: 'Atlassian',
|
||||
width: 2500,
|
||||
height: 2500,
|
||||
showLogo: true,
|
||||
},
|
||||
{ src: '/assets/logos/atlassian.svg', alt: 'Atlassian', width: 2500, height: 2500 },
|
||||
{
|
||||
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;
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
<!-- 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 ?? ''} />
|
||||
@@ -138,26 +132,15 @@
|
||||
|
||||
<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" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
|
||||
{#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/scripts/copyright-year.js" defer></script>
|
||||
</svelte:head>
|
||||
|
||||
<a href="#main" class="skip-link" data-umami-event="skip to main content"
|
||||
>Skip to main content</a
|
||||
>
|
||||
<a href="#main" class="skip-link">Skip to main content</a>
|
||||
{@render children()}
|
||||
<img
|
||||
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
|
||||
alt=""
|
||||
width="1"
|
||||
height="1"
|
||||
role="presentation"
|
||||
loading="eager"
|
||||
/>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,20 +0,0 @@
|
||||
<!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. We’re not sure it’s coming back.</p>
|
||||
<p><a href="/">Back to mifi Ventures →</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +0,0 @@
|
||||
<!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. We’ve moved on—and so should you.</p>
|
||||
<p><a href="/">Back to mifi Ventures →</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 115 KiB |
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,88 +0,0 @@
|
||||
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);
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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 });
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,9 +1,15 @@
|
||||
<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>
|
||||
<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 After Width: | Height: | Size: 1.7 KiB |
@@ -9,8 +9,8 @@ Allow: /
|
||||
# Disallow specific paths if needed
|
||||
# Disallow: /private/
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://mifi.ventures/sitemap.xml
|
||||
# Sitemap (add when available)
|
||||
# Sitemap: https://mifi.ventures/sitemap.xml
|
||||
|
||||
# Host preference (helps search engines understand the canonical domain)
|
||||
# Host: https://mifi.ventures
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @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: jsdom’s 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.
|
||||
});
|
||||
BIN
tests/visual.spec.ts-snapshots/home-chromium-darwin.png
Normal file
|
After Width: | Height: | Size: 591 KiB |
|
Before Width: | Height: | Size: 577 KiB |
@@ -11,6 +11,6 @@
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "static/assets/js/copyright-year.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ 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,
|
||||
|
||||