Compare commits

..

14 Commits

Author SHA1 Message Date
263bf9d2d3 Added the Nav... Almost ready for the switcheroo
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-02-01 02:42:04 -03:00
f39b90c41e Linty fresh 2026-02-01 02:42:03 -03:00
c9e32fa352 GA Fixes and CWV improvements 2026-02-01 02:42:03 -03:00
65e1f39176 Add Google Analytics script to page layout 2026-02-01 02:42:03 -03:00
0b507e14ad More refinements 2026-02-01 02:42:03 -03:00
232b107a3f Try this... 2026-02-01 02:42:03 -03:00
31052d2d9d Pipeline re-working v2 2026-02-01 02:42:03 -03:00
df7e2b2bcc New pipelines... 2026-02-01 02:42:02 -03:00
203f141291 OCD changes 2026-02-01 02:42:02 -03:00
266cd633c3 CSS Linty Fresh 2026-02-01 02:42:02 -03:00
d49104f4a7 Linty fresh (supposedly) 2026-02-01 02:42:02 -03:00
8d688291df Some enhancements to the pipeline... Liking woodpecker... 2026-02-01 02:42:02 -03:00
0c34d37110 That's a picky error... 2026-02-01 02:42:02 -03:00
44f6743b45 The Svelte 5 SSG migration—we brought sexy back... or to it? Or something... 2026-02-01 02:42:01 -03:00
51 changed files with 159 additions and 2100 deletions

View File

@@ -1,11 +1,18 @@
# Dev container: same image as CI e2e (Playwright Noble) so visual snapshots match. # Dev container for mifi Ventures static site
# Snapshots generated in the devcontainer will match CI; no need to run update-snapshots in CI. # Lightweight: Node for static server (npx serve), no app dependencies
FROM mcr.microsoft.com/playwright:v1.58.0-noble
# pnpm for this project (CI uses the same) FROM mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
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 RUN mkdir -p /workspaces/mifi-ventures-landing
# Default working directory
WORKDIR /workspaces/mifi-ventures-landing 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

View File

@@ -3,9 +3,6 @@
"dockerFile": "Dockerfile", "dockerFile": "Dockerfile",
"workspaceFolder": "/workspaces/mifi-ventures-landing", "workspaceFolder": "/workspaces/mifi-ventures-landing",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/mifi-ventures-landing,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/mifi-ventures-landing,type=bind",
"runArgs": ["-u", "root"],
"remoteUser": "root",
"postCreateCommand": "pnpm install",
"forwardPorts": [5173, 4173], "forwardPorts": [5173, 4173],
"portsAttributes": { "portsAttributes": {
"5173": { "5173": {
@@ -21,10 +18,7 @@
"vscode": { "vscode": {
"extensions": [ "extensions": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "esbenp.prettier-vscode"
"rvest.vs-code-prettier-eslint",
"yoavbls.pretty-ts-errors",
"svelte.svelte-vscode"
], ],
"settings": { "settings": {
"files.associations": { "files.associations": {
@@ -40,5 +34,6 @@
} }
} }
} }
} },
"remoteUser": "node"
} }

View File

@@ -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. # 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. # Deploy workflow depends on this (ci) and runs only on main.
when: when:
@@ -19,133 +19,17 @@ steps:
image: node:20-alpine image: node:20-alpine
commands: commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate - corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run lint - pnpm run lint
- pnpm run lint:css - 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 - name: build
image: node:20-alpine image: node:20-alpine
commands: commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate - corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install
- pnpm run build - pnpm run build
depends_on:
- unit test
- name: Send Test Build Status Notification (failure) - name: test
image: curlimages/curl image: node:20-alpine
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: commands:
- corepack enable && corepack prepare pnpm@10.28.2 --activate - corepack enable && corepack prepare pnpm@10.28.2 --activate
- pnpm install --frozen-lockfile || pnpm install - pnpm test
- 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,10 +2,8 @@
# Runs on push to main, tag, or manual (only when on main). # Runs on push to main, tag, or manual (only when on main).
# Waits for ci workflow (install → lint → build → test) to succeed first. # Waits for ci workflow (install → lint → build → test) to succeed first.
when: when:
- branch: main branch: main
event: [push, tag, manual] event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on: depends_on:
- ci - ci
@@ -14,7 +12,6 @@ steps:
- name: 'Docker image build' - name: 'Docker image build'
image: docker:latest image: docker:latest
environment: environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@@ -32,46 +29,9 @@ steps:
. .
- echo "✓ Docker image built successfully" - 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' - name: 'Push to registry'
image: docker:latest image: docker:latest
environment: environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing REGISTRY_REPO: git.mifi.dev/mifi-ventures/landing
REGISTRY_USERNAME: REGISTRY_USERNAME:
@@ -95,42 +55,6 @@ steps:
depends_on: depends_on:
- 'Docker image build' - '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' - name: 'Trigger Portainer stack redeploy'
image: curlimages/curl:latest image: curlimages/curl:latest
environment: environment:
@@ -150,39 +74,3 @@ steps:
echo "✓ Portainer redeploy triggered (HTTP $code)" echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on: depends_on:
- 'Push to registry' - '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

@@ -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

View File

@@ -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`).

View File

@@ -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 build` | SvelteKit build → `dist/`, then Critters inlines critical CSS |
| `pnpm run preview` | Serve `dist/` (Critters-processed) at http://localhost:4173 — same as deployed | | `pnpm run preview` | Serve `dist/` (Critters-processed) at http://localhost:4173 — same as deployed |
| `pnpm test` | Run unit tests (Vitest) | | `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` | ESLint (JS/TS/Svelte) |
| `pnpm run lint:css` | Stylelint (global CSS + Svelte styles) | | `pnpm run lint:css` | Stylelint (global CSS + Svelte styles) |
| `pnpm run format` | Prettier (JS/TS/Svelte/CSS/JSON) | | `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 ### 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. 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**. 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: **Inside the container**, run:
@@ -74,21 +72,7 @@ pnpm install
pnpm run dev 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. The site is served at **http://localhost:5173** (or the port shown) with live reload (port forwarded automatically).
### 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) ### 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_username` | SSH username | `deploy` or `root` |
| `deploy_ssh_key` | Private SSH key (multi-line) | `-----BEGIN OPENSSH PRIVATE KEY-----...` | | `deploy_ssh_key` | Private SSH key (multi-line) | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
| `deploy_port` | SSH port | `22` (default) | | `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:** **Generate SSH key for deployment:**

View File

@@ -13,9 +13,8 @@ export default [
'node_modules/**', 'node_modules/**',
'site/**', 'site/**',
'static/**', 'static/**',
'build.mjs', 'build.mjs'
'playwright-report/**', ]
],
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
@@ -28,19 +27,19 @@ export default [
parser: tseslint.parser, parser: tseslint.parser,
projectService: true, projectService: true,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
svelteConfig, svelteConfig
}, }
}, }
}, },
{ {
files: ['**/*.mjs', 'build.mjs'], files: ['**/*.mjs', 'build.mjs'],
languageOptions: { globals: { console: 'readonly', process: 'readonly' } }, languageOptions: { globals: { console: 'readonly', process: 'readonly' } }
}, },
{ {
rules: { rules: {
'svelte/no-at-html-tags': 'warn', 'svelte/no-at-html-tags': 'warn',
'svelte/require-each-key': 'off', 'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off', 'svelte/no-navigation-without-resolve': 'off'
}, }
}, }
]; ];

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -108,42 +108,14 @@ http {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 410 Gone: permanently removed URLs (tells crawlers to deindex) # Deny access to hidden files (.git, .env, etc.)
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 ~ /\. { location ~ /\. {
deny all; deny all;
access_log off; access_log off;
log_not_found off; log_not_found off;
} }
# Custom 404 page (for missing static assets; SPA routes still try index.html first via try_files) # 404 falls back to index.html for SPA-style routing
error_page 404 /404.html; error_page 404 /index.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", "version": "2.0.0",
"private": true, "private": true,
"repository": "https://git.mifi.dev/mifi-ventures/landing.git", "repository": "https://git.mifi.dev/mifi-ventures/landing.git",
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "packageManager": "pnpm@10.28.2",
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining", "description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
"type": "module", "type": "module",
"scripts": { "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", "dev": "vite dev",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -20,7 +20,6 @@
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:update-snapshots": "bash scripts/update-e2e-snapshots.sh",
"test:all": "vitest run && playwright test", "test:all": "vitest run && playwright test",
"test:watch": "vitest" "test:watch": "vitest"
}, },
@@ -30,13 +29,10 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.0", "@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/gtag.js": "^0.0.20",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.54.0",
"critters": "^0.0.24", "critters": "^0.0.24",
"esbuild": "^0.24.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"jsdom": "^25.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
"postcss-html": "^1.8.1", "postcss-html": "^1.8.1",
@@ -54,8 +50,5 @@
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
},
"dependencies": {
"@lucide/svelte": "^0.563.1"
} }
} }

View File

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

748
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View File

@@ -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);
});

View File

@@ -1,42 +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"
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

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

View File

@@ -375,13 +375,6 @@ a {
} }
} }
.icon-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
/* PRIMARY CTA /* PRIMARY CTA
Use the CTA/button tokens (defined in BOTH modes) to guarantee contrast. 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. */ This fixes the dark-mode purple/white contrast violation without changing your purple brand accents. */

View File

@@ -8,10 +8,12 @@
aria-labelledby="experience-heading" aria-labelledby="experience-heading"
> >
<div class="container"> <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"> <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"> <div class="logo-item" role="listitem">
<img <img
src={logo.src} src={logo.src}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -14,29 +14,15 @@
<span class="mobile nav-header-logo"> <span class="mobile nav-header-logo">
<Wordmark /> <Wordmark />
</span> </span>
<button <label for="nav-toggle" class="nav-toggle" aria-label="Toggle navigation">
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-inner">
<span class="nav-toggle-line"></span> <span class="nav-toggle-line"></span>
<span class="nav-toggle-line"></span> <span class="nav-toggle-line"></span>
<span class="nav-toggle-line"></span> <span class="nav-toggle-line"></span>
</span> </span>
</label> </label>
</button>
</div> </div>
<div id="nav-menu" class="nav-menu container"> <div class="nav-menu container">
<span class="nav-header-logo desktop"> <span class="nav-header-logo desktop">
<Wordmark /> <Wordmark />
</span> </span>
@@ -65,17 +51,17 @@
background-color: var(--color-bg); background-color: var(--color-bg);
background-color: var(--color-bg); background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border); 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; position: sticky;
text-align: center; text-align: center;
top: 0; top: 0;
z-index: 100; z-index: 100;
@media (max-width: 768px) { @media (max-width: 768px) {
align-items: stretch;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--space-md) 0; align-items: stretch;
} }
} }
@@ -156,14 +142,6 @@
} }
} }
.btn-clear {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
}
.nav-toggle { .nav-toggle {
display: none; display: none;
align-items: center; align-items: center;
@@ -303,56 +281,28 @@
} }
@supports (animation-timeline: scroll()) { @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-back-to-top,
.nav-header-logo { .nav-header-logo {
opacity: 0; opacity: 0;
visibility: hidden;
pointer-events: none;
animation: nav-reveal-on-scroll linear; animation: nav-reveal-on-scroll linear;
animation-timeline: scroll(root block); animation-timeline: scroll(root block);
animation-range: 300px 400px; animation-range: 300px 400px;
animation-fill-mode: both; animation-fill-mode: both;
will-change: opacity;
} }
@keyframes nav-reveal-on-scroll { @keyframes nav-reveal-on-scroll {
from { from {
opacity: 0; opacity: 0;
visibility: hidden;
pointer-events: none;
} }
to { to {
opacity: 1; opacity: 1;
visibility: visible;
pointer-events: auto;
} }
} }

View File

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

View File

@@ -1,52 +1,25 @@
export const experienceLogos = [ 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', src: '/assets/logos/tjx.svg',
alt: 'TJ Maxx (The TJX Companies)', alt: 'TJ Maxx (The TJX Companies)',
width: 2500, width: 2500,
height: 621, 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', src: '/assets/logos/bottomline.svg',
alt: 'Bottomline Technologies', alt: 'Bottomline Technologies',
width: 2702, width: 2702,
height: 571, height: 571,
showLogo: true,
}, },
{ {
src: '/assets/logos/mfa-boston.svg', src: '/assets/logos/mfa-boston.svg',
alt: 'Museum of Fine Arts Boston', alt: 'Museum of Fine Arts Boston',
width: 572, width: 572,
height: 88, height: 88,
showLogo: true,
}, },
] as const; ] as const;

View File

@@ -132,15 +132,14 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <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} {#if jsonLdHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- static trusted JSON-LD --> <!-- eslint-disable-next-line svelte/no-at-html-tags -- static trusted JSON-LD -->
{@html jsonLdHtml} {@html jsonLdHtml}
{/if} {/if}
<script src="/assets/js/copyright-year.js" defer></script> <script src="/assets/scripts/copyright-year.js" defer></script>
<script src="/assets/js/mobile-menu-helper.js" defer></script>
</svelte:head> </svelte:head>
<a href="#main" class="skip-link">Skip to main content</a> <a href="#main" class="skip-link">Skip to main content</a>

View File

@@ -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

View File

@@ -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. Were not sure its coming back.</p>
<p><a href="/">Back to mifi Ventures →</a></p>
</main>
</body>
</html>

View File

@@ -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. 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: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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);

File diff suppressed because one or more lines are too long

Before

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,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;"> <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> <style>
.block { fill: #0b0b0f; } .bg { fill: #0b0b0f; }
.fg { fill: #fff; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.block { fill: #f2f2f2; } .bg { fill: #f2f2f2; }
.fg { fill: #0b0b0f; }
} }
</style> </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" /> <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> </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 specific paths if needed
# Disallow: /private/ # Disallow: /private/
# Sitemap # Sitemap (add when available)
Sitemap: https://mifi.ventures/sitemap.xml # Sitemap: https://mifi.ventures/sitemap.xml
# Host preference (helps search engines understand the canonical domain) # Host preference (helps search engines understand the canonical domain)
# Host: https://mifi.ventures # Host: https://mifi.ventures

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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: 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.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

View File

@@ -11,6 +11,6 @@
"strict": true, "strict": true,
"moduleResolution": "bundler" "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"] "exclude": ["node_modules"]
} }

View File

@@ -5,12 +5,6 @@ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
host: true, // listen on 0.0.0.0 so reachable from host (e.g. dev container, port forward) 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: { preview: {
host: true, host: true,