Now with pre-commit hooks, to stop this ridiculousness. Linty fresh. Pretty. And up-to-date dependencies.
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
2026-03-09 21:46:03 -03:00
parent 9e692d072b
commit d4a7fc19b6
56 changed files with 905 additions and 1670 deletions

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm run test:ci

View File

@@ -10,7 +10,7 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
## Stack and architecture ## 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`). - **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/generate-sitemap.mjs``node scripts/minify-static-js.mjs`. Output is `dist/` (static files only). Deploy uses this; no 410 path copies. For local preview with 410 URLs working, use `pnpm run build-preview` (adds `copy-410-paths.mjs`). The 410 path dirs are in `.dockerignore` so they are never included in the image. - **Build**: `pnpm run build` = `vite build``node scripts/beasties.mjs``node scripts/generate-sitemap.mjs``node scripts/minify-static-js.mjs`. Output is `dist/` (static files only). Deploy uses this; no 410 path copies. For local preview with 410 URLs working, use `pnpm run build-preview` (adds `copy-410-paths.mjs`). The 410 path dirs are in `.dockerignore` so they are never included in the image.
- **Runtime**: nginx serves `dist/` (mounted as `/usr/share/nginx/html` in the container). No Node at runtime. - **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. - **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. - **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.
@@ -27,7 +27,7 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
| `src/lib/data/*.ts` | Content and meta (home-meta, content, experience, engagements, json-ld). Edit here for copy or SEO. | | `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()`. | | `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. | | `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/beasties.mjs` | Post-build: inlines critical CSS into **every** `dist/*.html` (including 404 and 410). Resolves stylesheet URLs relative to `dist/`. |
| `scripts/minify-static-js.mjs` | Post-build: minifies JS in `dist/assets/`. | | `scripts/minify-static-js.mjs` | Post-build: minifies JS in `dist/assets/`. |
| `scripts/copy-410-paths.mjs` | Run by `build-preview` only: copies `410.html` to each 410 URL path as `index.html` so static preview (e.g. `serve dist`) shows the 410 page at those URLs. Production uses `build` (no copy); nginx returns 410 via explicit location blocks and `error_page 410 /410.html`. | | `scripts/copy-410-paths.mjs` | Run by `build-preview` only: copies `410.html` to each 410 URL path as `index.html` so static preview (e.g. `serve dist`) shows the 410 page at those URLs. Production uses `build` (no copy); nginx returns 410 via explicit location blocks and `error_page 410 /410.html`. |
| `nginx.conf` | Serves static files; `try_files $uri $uri/ /index.html` for SPA-style fallback; `error_page 404 /404.html` and `error_page 410 /410.html` for custom error pages. | | `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. |
@@ -36,8 +36,8 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
- **Files**: `static/404.html`, `static/410.html`. They are **standalone HTML** (not Svelte). Do not convert them to Svelte routes. - **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`. - **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: - **Critical CSS**: The build runs Beasties on all `dist/*.html`. Beasties inlines critical CSS from linked stylesheets (including `/assets/error-pages.css`) into 404.html and 410.html. So:
- **Do** keep the `<link rel="stylesheet" href="/assets/error-pages.css">` in 404.html and 410.html; Critters will inline it at build time. - **Do** keep the `<link rel="stylesheet" href="/assets/error-pages.css">` in 404.html and 410.html; Beasties will inline it at build time.
- **Do not** add error-page-only CSS in `src/app.css`; the app bundle is not loaded on error pages. - **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)`). - **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. - **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.
@@ -54,8 +54,8 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
3. **External fonts** 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). 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** 4. **Skipping Beasties 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). Any new `.html` in `static/` is copied to `dist/` and **must** be processed by Beasties (the script already runs on all `dist/*.html`). Do not add static HTML that bypasses the build or that uses only inline styles without a linked stylesheet (linked styles get inlined by Beasties).
5. **Diverging error page theme** 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. 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.
@@ -71,5 +71,5 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
- **Change copy or structure (home)**: `src/lib/data/*.ts`, `src/lib/components/*.svelte`, `src/routes/+page.svelte`. - **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 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. - **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). - **Add a new static HTML page**: Add it under `static/`, link to `/assets/error-pages.css` (or a dedicated stylesheet that Beasties can inline). Ensure `scripts/beasties.mjs` runs over all `dist/*.html` (it already does).
- **Change nginx behavior**: `nginx.conf` (e.g. cache headers, `error_page`, `try_files`). - **Change nginx behavior**: `nginx.conf` (e.g. cache headers, `error_page`, `try_files`).

View File

@@ -34,10 +34,13 @@ This project uses **pnpm** as the package manager. After cloning, run `pnpm inst
| `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` | Run Playwright visual regression (e2e) |
| `pnpm run test:e2e:update-snapshots` | Regenerate e2e snapshots (in devcontainer = CI; see Visual regression) | | `pnpm run test:e2e:update-snapshots` | Regenerate e2e snapshots (in devcontainer = CI; see Visual regression) |
| `pnpm run test:ci` | Run same tests as CI: lint, lint:css, unit tests, e2e (used by pre-commit hook) |
| `pnpm run lint` | ESLint (JS/TS/Svelte) | | `pnpm run lint` | 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) |
A **pre-commit hook** (Husky) runs `pnpm run test:ci` so the same tests as CI run before each commit. Skip with `git commit --no-verify` if needed.
### Option 1: pnpm dev (recommended for editing) ### Option 1: pnpm dev (recommended for editing)
From the project root: From the project root:
@@ -88,7 +91,7 @@ E2e tests use Playwright and compare full-page screenshots to committed snapshot
**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. **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. **Running e2e locally:** `pnpm run test:e2e` mirrors CI when Docker is available (runs tests in the same Playwright image). Without Docker (e.g. inside the devcontainer), it runs in the current environment. The config uses Linux snapshot paths so baselines stay consistent; run in Docker or devcontainer for matching rendering.
### Option 4: Docker (Production-like Test) ### Option 4: Docker (Production-like Test)
@@ -127,7 +130,7 @@ mifi-ventures-landing/
├── svelte.config.js # SvelteKit config (adapter-static) ├── svelte.config.js # SvelteKit config (adapter-static)
├── vite.config.ts # Vite config ├── vite.config.ts # Vite config
├── postcss.config.js # PostCSS (autoprefixer) ├── postcss.config.js # PostCSS (autoprefixer)
├── scripts/critters.mjs # Post-build critical CSS inlining ├── scripts/beasties.mjs # Post-build critical CSS inlining
├── static/ # Static assets (copied to dist as-is) ├── static/ # Static assets (copied to dist as-is)
│ ├── favicon.svg, favicon.ico, robots.txt │ ├── favicon.svg, favicon.ico, robots.txt
│ ├── copyright-year.js # Minimal client script (footer year) │ ├── copyright-year.js # Minimal client script (footer year)

View File

@@ -1,13 +1,13 @@
{ {
"name": "mifi-ventures-landing", "name": "mifi-ventures-landing",
"version": "2.0.0", "version": "3.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.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"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/generate-sitemap.mjs && node scripts/minify-static-js.mjs", "build": "vite build && node scripts/beasties.mjs && node scripts/generate-sitemap.mjs && node scripts/minify-static-js.mjs",
"build-preview": "pnpm run build && node scripts/copy-410-paths.mjs", "build-preview": "pnpm run build && node scripts/copy-410-paths.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",
@@ -20,26 +20,29 @@
"preview": "serve dist -p 4173", "preview": "serve dist -p 4173",
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:e2e": "playwright test", "test:e2e": "bash scripts/run-e2e.sh",
"test:e2e:update-snapshots": "bash scripts/update-e2e-snapshots.sh", "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:ci": "pnpm run lint && pnpm run lint:css && pnpm exec svelte-kit sync && pnpm test && pnpm run test:e2e",
"test:watch": "vitest",
"prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.1", "@playwright/test": "^1.58.1",
"@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": "^6.2.4",
"@types/gtag.js": "^0.0.20", "@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", "beasties": "^0.4.1",
"esbuild": "^0.24.0", "esbuild": "^0.27.3",
"eslint": "^9.39.2", "eslint": "^10.0.3",
"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.15.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"postcss-html": "^1.8.1", "postcss-html": "^1.8.1",
"postcss-preset-env": "^11.1.2", "postcss-preset-env": "^11.1.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -53,10 +56,10 @@
"tslib": "^2.8.0", "tslib": "^2.8.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"vite": "^6.0.0", "vite": "^7.3.1",
"vitest": "^4.0.18" "vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"@lucide/svelte": "^0.563.1" "@lucide/svelte": "^0.577.0"
} }
} }

View File

@@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: 'tests', testDir: 'tests',
testMatch: /.*\.spec\.(ts|js)/, testMatch: /.*\.spec\.(ts|js)/,
// Use linux in snapshot paths so local (darwin) runs compare against the same snapshots as CI.
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-linux{ext}',
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
@@ -13,7 +15,10 @@ export default defineConfig({
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], projects: [
{ name: 'chromium-desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'chromium-mobile', use: { ...devices['iPhone 14'] } },
],
webServer: process.env.CI webServer: process.env.CI
? undefined ? undefined
: { : {

1427
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

47
scripts/beasties.mjs Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Post-build: inline critical CSS in dist/*.html (SvelteKit adapter-static output).
* Runs after vite build; Beasties reads/writes relative to dist/.
*
* Beasties with preload:'default' adds preload tags; same options as legacy Critters.
*/
import Beasties from 'beasties';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
async function main() {
if (!fs.existsSync(DIST)) {
console.error('dist/ not found. Run vite build first.');
process.exit(1);
}
const beasties = new Beasties({
path: DIST,
preload: 'default',
noscriptFallback: true,
pruneSource: false,
logLevel: 'warn',
});
const files = fs.readdirSync(DIST).filter((f) => f.endsWith('.html'));
for (const file of files) {
const filePath = path.join(DIST, file);
let html = fs.readFileSync(filePath, 'utf8');
html = await beasties.process(html);
fs.writeFileSync(filePath, html, 'utf8');
console.log('✓ Critical CSS inlined → dist/' + file);
}
console.log('Critical CSS step complete.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

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

View File

@@ -2,7 +2,7 @@
/** /**
* Post-build: generate sitemap.xml from prerendered pages in dist/. * Post-build: generate sitemap.xml from prerendered pages in dist/.
* Scans for index.html (root and under each path), excludes 410 paths. * Scans for index.html (root and under each path), excludes 410 paths.
* Run after vite build and critters, before copy-410-paths so 410 dirs don't exist yet. * Run after vite build and beasties, before copy-410-paths so 410 dirs don't exist yet.
*/ */
import fs from 'fs'; import fs from 'fs';

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Post-build: minify all JS in dist/assets/js/ (static scripts copied from static/assets/js/). * 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. * Runs after vite build (and optionally after beasties). Uses esbuild for minification.
*/ */
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';

27
scripts/run-e2e-in-docker.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Run Playwright e2e tests in the same Docker image as CI (and as snapshot generation).
# Use when running locally on macOS/Windows so tests mirror CI; in CI or devcontainer use pnpm test:e2e directly.
set -e
SCRIPT_DIR="${BASH_SOURCE%/*}"
PROJECT_ROOT="${SCRIPT_DIR}/.."
cd "$PROJECT_ROOT"
PROJECT_ROOT="$(pwd)"
PLAYWRIGHT_IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.58.0-noble}"
echo "Running e2e tests in Docker image: $PLAYWRIGHT_IMAGE (same as CI)"
echo "Project root: $PROJECT_ROOT"
echo ""
docker run --rm \
-v "$PROJECT_ROOT:/app" -w /app \
-e CI=1 \
"$PLAYWRIGHT_IMAGE" \
bash -c '
corepack enable && corepack prepare pnpm@10.28.2 --activate
pnpm install --no-frozen-lockfile || pnpm install
pnpm run build
npx serve dist -p 4173 &
sleep 2
pnpm exec playwright test
'

19
scripts/run-e2e.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Run Playwright e2e tests. Mirrors CI when possible.
# - In CI: run playwright test (pipeline already built and started serve).
# - Local with Docker: run tests in same Playwright image as CI (run-e2e-in-docker.sh).
# - Local without Docker (e.g. devcontainer): build and run playwright test (webServer in config).
set -e
if [ -n "$CI" ]; then
pnpm exec playwright test
exit 0
fi
if command -v docker >/dev/null 2>&1; then
exec bash "$(dirname "$0")/run-e2e-in-docker.sh"
fi
# No Docker: run in current environment (e.g. devcontainer; same image as CI)
pnpm run build
pnpm exec playwright test

View File

@@ -490,144 +490,44 @@ a {
top: 0.1em; top: 0.1em;
} }
} }
}
.content-list--ordered { &.ordered {
list-style: decimal; list-style: decimal;
padding-left: var(--space-xl); padding-left: var(--space-xl);
}
.content-list--ordered li { & li {
padding-left: var(--space-sm); padding-left: var(--space-sm);
}
.content-list--ordered li::before { &::before {
content: none; content: none;
} }
}
/* ========================================
Service Pages: Credibility Strip, Who Grid, FAQ
======================================== */
.service-credibility {
background-color: var(--color-bg-subtle);
}
.service-credibility__list {
list-style: none;
margin: 0;
padding: 0;
max-width: var(--max-width);
margin: 0 auto;
display: grid;
gap: var(--space-md);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.service-credibility__list li {
position: relative;
padding-left: var(--space-lg);
font-size: var(--font-size-base);
line-height: var(--line-height-relaxed);
color: var(--color-text-secondary);
}
.service-credibility__list li::before {
content: '→';
position: absolute;
left: 0;
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.who-grid {
display: grid;
gap: var(--space-xxl);
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
.who-grid {
grid-template-columns: 1fr;
} }
} }
.who-block h2,
.who-block .list-heading {
font-size: var(--font-size-xl);
margin-bottom: var(--space-md);
}
/* ======================================== /* ========================================
Services Landing: Card Grid, Engagements, Ideal Services Landing: Card Grid, Engagements, Ideal
======================================== */ ======================================== */
.services-grid-section {
background-color: var(--color-bg);
}
.services-card-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-xl);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.services-card {
padding: var(--space-xl);
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
display: flex;
flex-direction: column;
}
.services-card__title {
margin-bottom: var(--space-md);
font-size: var(--font-size-large);
font-weight: var(--font-weight-semibold);
}
.services-card__desc {
flex: 1;
margin-bottom: var(--space-lg);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.services-card__link {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: var(--font-weight-semibold);
}
.services-card__link span {
margin-left: var(--space-xs);
}
.engagements-list { .engagements-list {
margin: var(--space-lg) 0; margin: var(--space-lg) 0;
}
.engagements-list dt { & dt {
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-text); color: var(--color-text);
margin-top: var(--space-lg); margin-top: var(--space-lg);
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
}
.engagements-list dt:first-child { &:first-child {
margin-top: 0; margin-top: 0;
} }
}
.engagements-list dd { & dd {
margin: 0 0 0 var(--space-md); margin: 0 0 0 var(--space-md);
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: var(--line-height-relaxed); line-height: var(--line-height-relaxed);
}
} }
.services-intro { .services-intro {

View File

@@ -0,0 +1,61 @@
<script lang="ts">
interface BreadcrumbItem {
label: string;
href?: string;
}
const {
items = [],
}: {
items: BreadcrumbItem[];
} = $props();
</script>
<nav class="section breadcrumbs" aria-label="Breadcrumbs">
<div class="container">
<ol class="list">
{#each items as item, index}
<li class="item">
{#if item.href}
<a href={item.href}>{item.label}</a>
{#if index < items.length - 1}<span
class="separator"
aria-hidden="true">&gt;</span
>{/if}
{:else}
{item.label}
{/if}
</li>
{/each}
</ol>
</div>
</nav>
<style>
.breadcrumbs {
padding: var(--space-md) 0;
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
.list {
display: inline-block;
list-style: none;
margin: 0;
padding: 0;
}
.item {
display: inline;
}
.item a {
color: var(--color-text-secondary);
text-decoration: none;
}
.separator {
font-size: var(--font-size-small);
margin-inline: var(--space-xs);
}
</style>

View File

@@ -9,7 +9,7 @@
<p class="copyright"> <p class="copyright">
© <span id="copyright-year">2026</span> mifi Ventures, LLC. All rights reserved. © <span id="copyright-year">2026</span> mifi Ventures, LLC. All rights reserved.
</p> </p>
<nav class="footer-links footer-links--wrap" aria-label="Footer links"> <nav class="footer-links footer-links-wrap" aria-label="Footer links">
<a <a
class="link" class="link"
href="https://linkedin.com/in/the-mifi" href="https://linkedin.com/in/the-mifi"
@@ -72,7 +72,7 @@
max-width: 100%; max-width: 100%;
} }
.footer-links--wrap { .footer-links-wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }

View File

@@ -21,7 +21,7 @@
<section id={section.id} class={sectionClasses} aria-labelledby={headingId}> <section id={section.id} class={sectionClasses} aria-labelledby={headingId}>
<div class={containerClass}> <div class={containerClass}>
<h2 id={headingId} class:sr-only={section.headingSrOnly ?? false}> <h2 id={headingId} class={{ 'sr-only': section.headingSrOnly ?? false }}>
{section.heading} {section.heading}
</h2> </h2>
@@ -57,7 +57,7 @@
{/if} {/if}
{#if (section.orderedBullets?.length ?? 0) > 0} {#if (section.orderedBullets?.length ?? 0) > 0}
<ol class="content-list content-list--ordered"> <ol class="content-list ordered">
{#each section.orderedBullets as bullet} {#each section.orderedBullets as bullet}
<li>{bullet}</li> <li>{bullet}</li>
{/each} {/each}

View File

@@ -6,25 +6,36 @@
sectionId = 'services-grid', sectionId = 'services-grid',
headingId = 'services-heading', headingId = 'services-heading',
heading = 'Services', heading = 'Services',
overview = '',
surface = 'bg',
}: { }: {
services: ServiceCard[]; services: ServiceCard[];
sectionId?: string; sectionId?: string;
headingId?: string; headingId?: string;
heading?: string; heading?: string;
overview?: string;
surface?: 'bg' | 'bg-alt';
} = $props(); } = $props();
</script> </script>
<section id={sectionId} class="section services-grid-section" aria-labelledby={headingId}> <section
id={sectionId}
class={['section services-grid-section', { 'bg-alt': surface === 'bg-alt' }]}
aria-labelledby={headingId}
>
<div class="container"> <div class="container">
<h2 id={headingId} class="section-title">{heading}</h2> <h2 id={headingId} class="section-title">{heading}</h2>
{#if overview}
<p class="overview">{overview}</p>
{/if}
<ul class="services-card-list"> <ul class="services-card-list">
{#each services as service (service.href)} {#each services as service (service.href)}
<li class="services-card"> <li class={['services-card', { bg: surface === 'bg-alt' }]}>
<h3 class="services-card__title">{service.title}</h3> <h3 class="title">{service.title}</h3>
<p class="services-card__desc">{service.description}</p> <p class="desc">{service.description}</p>
<a <a
href={service.href} href={service.href}
class="services-card__link" class="link"
data-umami-event="service link" data-umami-event="service link"
data-umami-event-label={service.href} data-umami-event-label={service.href}
> >
@@ -36,3 +47,69 @@
</ul> </ul>
</div> </div>
</section> </section>
<style>
.services-grid-section {
background-color: var(--color-bg);
&.bg-alt {
background-color: var(--color-bg-alt);
}
}
.overview {
max-width: var(--max-text-width);
margin: 0 auto var(--space-xxl) auto;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
text-align: center;
}
.services-card-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-xl);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.services-card {
padding: var(--space-xl);
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
display: flex;
flex-direction: column;
&.bg {
background-color: var(--color-bg);
}
}
.title {
margin-bottom: var(--space-md);
font-size: var(--font-size-large);
font-weight: var(--font-weight-semibold);
}
.desc {
flex: 1;
margin-bottom: var(--space-lg);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.link {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: var(--font-weight-semibold);
& span {
margin-left: var(--space-xs);
}
}
</style>

View File

@@ -18,7 +18,7 @@
<section id="who-its-for" class="section" aria-labelledby="who-for-heading"> <section id="who-its-for" class="section" aria-labelledby="who-for-heading">
<div class="container"> <div class="container">
<h2 id="who-for-heading" class={{ 'sr-only': !showTitle }}>{title}</h2> <h2 id="who-for-heading" class={['title', { 'sr-only': !showTitle }]}>{title}</h2>
<div class="who-grid"> <div class="who-grid">
<div class="who-block"> <div class="who-block">
<h3 id="who-for-list-heading" class="list-heading">{whoForHeading}</h3> <h3 id="who-for-list-heading" class="list-heading">{whoForHeading}</h3>
@@ -51,6 +51,7 @@
} }
} }
.title,
.list-heading { .list-heading {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);

View File

@@ -1,114 +0,0 @@
<script lang="ts">
const services = [
{
title: 'Hands-On SaaS Architecture',
description:
'Build the foundations that let SaaS products evolve without accumulating structural debt.',
href: '/services/hands-on-saas-architecture-consultant',
},
{
title: 'MVP Architecture & Launch',
description:
'Ship quickly without creating a frontend mess or fragile product foundation.',
href: '/services/mvp-architecture-and-launch',
},
{
title: 'Fractional CTO / Technical Partner',
description:
'Technical leadership for teams that need architectural direction without hiring a full-time CTO.',
href: '/services/fractional-cto-for-early-stage-saas',
},
{
title: 'Stage-Aligned Infrastructure',
description:
"Infrastructure decisions that match your company's stage, without unnecessary SaaS sprawl or cloud complexity.",
href: '/services/stage-aligned-infrastructure',
},
];
</script>
<section
id="services"
class="section services-overview"
aria-labelledby="services-overview-heading"
>
<div class="container">
<h2 id="services-overview-heading" class="section-title">How I Help</h2>
<p class="services-overview__intro">
I work with early-stage SaaS teams in four primary ways, depending on the
stage of the product and the type of technical support needed.
</p>
<ul class="services-overview__list">
{#each services as service (service.href)}
<li class="services-overview__card">
<h3 class="services-overview__card-title">{service.title}</h3>
<p class="services-overview__card-desc">{service.description}</p>
<a
href={service.href}
class="services-overview__link"
data-umami-event="home service link"
data-umami-event-label={service.href}
>
Learn more
<span aria-hidden="true"></span>
</a>
</li>
{/each}
</ul>
</div>
</section>
<style>
.services-overview {
background-color: var(--color-bg-alt);
}
.services-overview__intro {
max-width: var(--max-text-width);
margin: 0 auto var(--space-xxl) auto;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
text-align: center;
}
.services-overview__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-xl);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.services-overview__card {
padding: var(--space-xl);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
}
.services-overview__card-title {
margin-bottom: var(--space-md);
font-size: var(--font-size-large);
font-weight: var(--font-weight-semibold);
}
.services-overview__card-desc {
margin-bottom: var(--space-lg);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.services-overview__link {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: var(--font-weight-semibold);
}
.services-overview__link span {
margin-left: var(--space-xs);
}
</style>

View File

@@ -0,0 +1,26 @@
export const services = [
{
title: 'Hands-On SaaS Architecture',
description:
'Build the foundations that let SaaS products evolve without accumulating structural debt.',
href: '/services/hands-on-saas-architecture-consultant',
},
{
title: 'MVP Architecture & Launch',
description:
'Ship quickly without creating a frontend mess or fragile product foundation.',
href: '/services/mvp-architecture-and-launch',
},
{
title: 'Fractional CTO / Technical Partner',
description:
'Technical leadership for teams that need architectural direction without hiring a full-time CTO.',
href: '/services/fractional-cto-for-early-stage-saas',
},
{
title: 'Stage-Aligned Infrastructure',
description:
"Infrastructure decisions that match your company's stage, without unnecessary SaaS sprawl or cloud complexity.",
href: '/services/stage-aligned-infrastructure',
},
];

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Navigation from '$lib/components/Navigation.svelte'; import Navigation from '$lib/components/Navigation.svelte';
import Hero from '$lib/components/home/Hero.svelte'; import HomeHero from '$lib/components/home/Hero.svelte';
import ServicesOverview from '$lib/components/home/ServicesOverview.svelte';
import ExperienceSection from '$lib/components/home/ExperienceSection.svelte'; import ExperienceSection from '$lib/components/home/ExperienceSection.svelte';
import WhatWeDo from '$lib/components/home/WhatWeDo.svelte'; import WhatWeDo from '$lib/components/home/WhatWeDo.svelte';
import ImpactSection from '$lib/components/home/ImpactSection.svelte'; import ImpactSection from '$lib/components/home/ImpactSection.svelte';
@@ -9,12 +8,14 @@
import EngagementsSection from '$lib/components/home/EngagementsSection.svelte'; import EngagementsSection from '$lib/components/home/EngagementsSection.svelte';
import ScheduleSection from '$lib/components/ScheduleSection.svelte'; import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import { items as navigationItems } from '$lib/data/home/navigation'; import { items as navigationItems } from '$lib/data/home/navigation';
import ServicesCardGrid from '$lib/components/ServicesCardGrid.svelte';
import { services } from '$lib/data/home/services';
</script> </script>
<Navigation items={navigationItems} page="home" /> <Navigation items={navigationItems} page="home" />
<Hero /> <HomeHero />
<main id="main"> <main id="main">
<ServicesOverview /> <ServicesCardGrid {services} surface="bg-alt" />
<ExperienceSection /> <ExperienceSection />
<WhatWeDo /> <WhatWeDo />
<ImpactSection /> <ImpactSection />

View File

@@ -1,436 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import ExternalLinkIcon from '$lib/components/Icon/ExternalLink.svelte';
import { faqItems } from '$lib/data/mvp-architecture-and-launch/faq';
const discoveryCallUrl =
'https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=mvp_arch_launch_page';
const navItems = [
{ label: 'Home', href: '/', umamiEventLabel: 'home' },
{ label: 'My approach', href: '#approach', umamiEventLabel: 'approach' },
{ label: 'Engagement', href: '#engagement', umamiEventLabel: 'engagement' },
{ label: 'FAQ', href: '#faq', umamiEventLabel: 'faq' },
{
label: 'Book a call',
href: discoveryCallUrl,
umamiEventLabel: 'book-call',
},
];
</script>
<Navigation items={navItems} page="mvp-architecture-and-launch" />
<header id="header" class="service-hero">
<div class="container">
<h1 class="service-hero__title">
MVP Architecture & Launch for Early-Stage SaaS
</h1>
<p class="service-hero__subhead">
Shipping fast is good. Shipping chaos is expensive. I help early-stage SaaS
teams build MVPs that move quickly without creating frontend debt, fragile
CSS, or structural problems that slow iteration six months later.
</p>
<div class="cta-group">
<a
href={discoveryCallUrl}
class="btn btn-primary icon-button"
target="_blank"
rel="noopener noreferrer"
aria-label="Book a discovery call (opens in new tab)"
data-umami-event="book discovery call"
data-umami-event-location="hero"
>
Book a discovery call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
<a
href="#approach"
class="btn btn-secondary"
data-umami-event="see how i work"
data-umami-event-location="hero"
>
See how I work
</a>
</div>
</div>
</header>
<main id="main">
<nav class="section service-toc" aria-label="Page contents">
<div class="container">
<h2 class="service-toc__title">On this page</h2>
<ul class="service-toc__list">
<li><a href="#common-pattern">The common MVP pattern</a></li>
<li>
<a href="#good-foundation">What a good MVP foundation looks like</a>
</li>
<li><a href="#approach">My approach</a></li>
<li><a href="#what-changes">What changes within 1&ndash;2 weeks</a></li>
<li><a href="#engagement">Engagement options</a></li>
<li><a href="#who-its-for">Who it's for</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#final-cta">Get in touch</a></li>
</ul>
</div>
</nav>
<section id="common-pattern" class="section" aria-labelledby="common-pattern-heading">
<div class="container narrow">
<h2 id="common-pattern-heading">
Most MVPs are built for speed—few are built for iteration
</h2>
<p>
Early MVPs often prioritize backend logic and feature delivery. The
frontend becomes an afterthought—functional, but brittle. Six months
later, every new feature feels heavier than the last.
</p>
<p>Common symptoms:</p>
<ul class="content-list">
<li>Poor separation of concerns</li>
<li>Backend-heavy architecture with fragile UI</li>
<li>Repeated components instead of reusable systems</li>
<li>Spaghetti CSS and specificity wars</li>
<li>Accessibility postponed</li>
<li>"We'll clean it up later" decisions compounding</li>
</ul>
<p>Speed isn't the problem. Structure is.</p>
</div>
</section>
<section
id="good-foundation"
class="section"
aria-labelledby="good-foundation-heading"
>
<div class="container narrow">
<h2 id="good-foundation-heading">MVP does not mean throwaway</h2>
<p>A well-built MVP is minimal—but intentional.</p>
<p>It includes:</p>
<ul class="content-list">
<li>Clear separation between layers</li>
<li>Reusable, composable frontend components</li>
<li>Tokenized design systems (color, spacing, typography)</li>
<li>Clean, maintainable CSS architecture</li>
<li>Accessibility baked in from day one</li>
<li>A simple, predictable deployment path</li>
</ul>
<p>You can move fast and build correctly at the same time.</p>
<p>
<a href="/hands-on-saas-architecture-consultant"
>Hands-on SaaS architecture</a
>
·
<a href="/fractional-cto-for-early-stage-saas"
>Fractional CTO for early-stage SaaS</a
>
·
<a href="/stage-aligned-infrastructure">Stage-aligned infrastructure</a>
</p>
</div>
</section>
<section id="approach" class="section" aria-labelledby="approach-heading">
<div class="container narrow">
<h2 id="approach-heading">Architecture through implementation</h2>
<p>I don't deliver diagrams and disappear. I work inside your codebase.</p>
<p>My approach:</p>
<ol class="content-list content-list--ordered">
<li>Fix the CSS foundation first.</li>
<li>Extract and standardize reusable components.</li>
<li>Introduce design tokens to prevent duplication.</li>
<li>Align frontend and backend boundaries.</li>
<li>Improve accessibility and semantics incrementally.</li>
<li>Keep shipping while refactoring.</li>
</ol>
<p>No rewrite mandates. No velocity freeze.</p>
</div>
</section>
<section id="what-changes" class="section" aria-labelledby="what-changes-heading">
<div class="container narrow">
<h2 id="what-changes-heading">What teams notice quickly</h2>
<p>
In most cases, teams feel the difference within 12 weeks once
foundational issues are corrected.
</p>
<p>You'll see:</p>
<ul class="content-list">
<li>Faster feature implementation</li>
<li>Lower bug rates</li>
<li>More consistent UI</li>
<li>Safer refactors</li>
<li>Increased release confidence</li>
<li>Better team morale</li>
</ul>
<p>
It's all one big ball of yarn—clean up the foundation and everything moves
more smoothly.
</p>
</div>
</section>
<section id="engagement" class="section" aria-labelledby="engagement-heading">
<div class="container narrow">
<h2 id="engagement-heading">How we can work together</h2>
<h3>MVP Architecture Engagement (fixed scope)</h3>
<ul class="content-list">
<li>Codebase review focused on frontend foundations</li>
<li>Structural audit and prioritized roadmap</li>
<li>Component system extraction plan</li>
<li>CSS cleanup and token strategy</li>
<li>Accessibility baseline</li>
</ul>
<h3>Hands-On Implementation (optional)</h3>
<ul class="content-list">
<li>Direct refactoring and component system creation</li>
<li>Tokenized design system rollout</li>
<li>Pairing with your engineers</li>
<li>Documentation and knowledge transfer</li>
</ul>
<h3>Ongoing Advisory (optional)</h3>
<ul class="content-list">
<li>Periodic architecture reviews</li>
<li>Guardrails as you scale</li>
<li>Guidance on feature/system tradeoffs</li>
</ul>
</div>
</section>
<section id="who-its-for" class="section" aria-labelledby="who-for-heading">
<div class="container">
<div class="who-grid">
<div class="who-block">
<h2 id="who-for-heading">Ideal fit</h2>
<ul class="content-list">
<li>Founder-led SaaS teams</li>
<li>110 engineers</li>
<li>Recently launched MVP</li>
<li>Feeling UI friction or code fragility</li>
<li>Want adult-level architecture without slowing down</li>
</ul>
</div>
<div class="who-block">
<h2 id="who-not-heading">Who this is not for</h2>
<ul class="content-list">
<li>
Teams who only want features shipped as fast as possible
without regard for structure
</li>
<li>Organizations looking purely for architecture slide decks</li>
<li>Large enterprises needing formal procurement processes</li>
</ul>
</div>
</div>
</div>
</section>
<section id="faq" class="section service-faq" aria-labelledby="faq-heading">
<div class="container narrow">
<h2 id="faq-heading">FAQ</h2>
<dl class="faq-list">
{#each faqItems as item (item.question)}
<dt>{item.question}</dt>
<dd>{item.answer}</dd>
{/each}
</dl>
</div>
</section>
<section
id="final-cta"
class="section schedule-section"
aria-labelledby="final-cta-heading"
>
<div class="container">
<h2 id="final-cta-heading">Ready to stabilize your MVP?</h2>
<p class="schedule-text">
If your MVP shipped fast but now feels fragile, let's reinforce the
foundation before iteration slows further.
</p>
<div class="cta-group">
<a
href={discoveryCallUrl}
class="btn btn-primary icon-button"
target="_blank"
rel="noopener noreferrer"
aria-label="Book a discovery call (opens in new tab)"
data-umami-event="book discovery call"
data-umami-event-location="final cta"
>
Book a discovery call
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
</a>
<a
href="mailto:hello@mifi.ventures"
class="btn btn-secondary"
data-umami-event="email"
data-umami-event-location="final cta"
>
Email me
</a>
</div>
</div>
</section>
</main>
<Footer />
<style>
.service-hero {
padding: var(--space-xxxl) 0 var(--space-xxl) 0;
text-align: center;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.service-hero__title {
margin-bottom: var(--space-lg);
font-family: var(--font-family-heading);
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
letter-spacing: -0.03em;
max-width: var(--max-text-width);
margin-left: auto;
margin-right: auto;
}
.service-hero__subhead {
max-width: var(--max-narrow-width);
margin: 0 auto var(--space-xl) auto;
font-size: var(--font-size-large);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.cta-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
justify-content: center;
align-items: center;
margin-top: var(--space-lg);
}
@media (max-width: 768px) {
.cta-group {
flex-direction: column;
width: 100%;
}
}
.narrow {
max-width: var(--max-narrow-width);
}
.service-toc {
padding: var(--space-xl) 0;
border-bottom: 1px solid var(--color-border);
}
.service-toc__title {
font-size: var(--font-size-large);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-md);
}
.service-toc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-xl);
}
.service-toc__list a {
color: var(--color-text-secondary);
font-size: var(--font-size-medium);
}
.service-toc__list a:hover {
color: var(--color-primary);
}
.content-list--ordered {
list-style: decimal;
padding-left: var(--space-xl);
}
.content-list--ordered li {
padding-left: var(--space-sm);
}
.content-list--ordered li::before {
content: none;
}
.who-grid {
display: grid;
gap: var(--space-xxl);
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
.who-grid {
grid-template-columns: 1fr;
}
}
.who-block h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-md);
}
.service-faq {
background-color: var(--color-bg-alt);
}
.faq-list {
margin: 0;
}
.faq-list dt {
font-weight: var(--font-weight-semibold);
color: var(--color-text);
margin-top: var(--space-xl);
margin-bottom: var(--space-sm);
}
.faq-list dt:first-child {
margin-top: 0;
}
.faq-list dd {
margin: 0 0 0 var(--space-md);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
max-width: var(--max-text-width);
}
.schedule-section {
text-align: center;
background-color: var(--color-bg-subtle);
}
.schedule-text {
margin-bottom: var(--space-lg);
font-size: var(--font-size-large);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
max-width: var(--max-text-width);
margin-left: auto;
margin-right: auto;
}
/* Avoid anchor targets sitting under sticky nav */
[id] {
scroll-margin-top: 6rem;
}
</style>

View File

@@ -8,10 +8,18 @@
import ScheduleSection from '$lib/components/ScheduleSection.svelte'; import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import { pageContent } from '$lib/data/services/fractional-cto-for-early-stage-saas/content'; import { pageContent } from '$lib/data/services/fractional-cto-for-early-stage-saas/content';
import { faqItems } from '$lib/data/services/fractional-cto-for-early-stage-saas/faq'; import { faqItems } from '$lib/data/services/fractional-cto-for-early-stage-saas/faq';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
</script> </script>
<Navigation items={pageContent.navItems} page="fractional-cto-for-early-stage-saas" /> <Navigation items={pageContent.navItems} page="fractional-cto-for-early-stage-saas" />
<Breadcrumbs
items={[
{ label: 'Services', href: '/services' },
{ label: 'Fractional CTO for Early-Stage SaaS' },
]}
/>
<Hero {...pageContent.hero} /> <Hero {...pageContent.hero} />
<main id="main"> <main id="main">

View File

@@ -8,10 +8,18 @@
import ScheduleSection from '$lib/components/ScheduleSection.svelte'; import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import { pageContent } from '$lib/data/services/hands-on-saas-architecture-consultant/content'; import { pageContent } from '$lib/data/services/hands-on-saas-architecture-consultant/content';
import { faqItems } from '$lib/data/services/hands-on-saas-architecture-consultant/faq'; import { faqItems } from '$lib/data/services/hands-on-saas-architecture-consultant/faq';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
</script> </script>
<Navigation items={pageContent.navItems} page="hands-on-saas-architecture-consultant" /> <Navigation items={pageContent.navItems} page="hands-on-saas-architecture-consultant" />
<Breadcrumbs
items={[
{ label: 'Services', href: '/services' },
{ label: 'Hands-On SaaS Architecture Consultant' },
]}
/>
<Hero {...pageContent.hero} /> <Hero {...pageContent.hero} />
<main id="main"> <main id="main">

View File

@@ -8,10 +8,18 @@
import ScheduleSection from '$lib/components/ScheduleSection.svelte'; import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import { pageContent } from '$lib/data/services/mvp-architecture-and-launch/content'; import { pageContent } from '$lib/data/services/mvp-architecture-and-launch/content';
import { faqItems } from '$lib/data/services/mvp-architecture-and-launch/faq'; import { faqItems } from '$lib/data/services/mvp-architecture-and-launch/faq';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
</script> </script>
<Navigation items={pageContent.navItems} page="mvp-architecture-and-launch" /> <Navigation items={pageContent.navItems} page="mvp-architecture-and-launch" />
<Breadcrumbs
items={[
{ label: 'Services', href: '/services' },
{ label: 'MVP Architecture & Launch' },
]}
/>
<Hero {...pageContent.hero} /> <Hero {...pageContent.hero} />
<main id="main"> <main id="main">

View File

@@ -8,10 +8,18 @@
import ScheduleSection from '$lib/components/ScheduleSection.svelte'; import ScheduleSection from '$lib/components/ScheduleSection.svelte';
import { pageContent } from '$lib/data/services/stage-aligned-infrastructure/content'; import { pageContent } from '$lib/data/services/stage-aligned-infrastructure/content';
import { faqItems } from '$lib/data/services/stage-aligned-infrastructure/faq'; import { faqItems } from '$lib/data/services/stage-aligned-infrastructure/faq';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
</script> </script>
<Navigation items={pageContent.navItems} page="stage-aligned-infrastructure" /> <Navigation items={pageContent.navItems} page="stage-aligned-infrastructure" />
<Breadcrumbs
items={[
{ label: 'Services', href: '/services' },
{ label: 'Stage-Aligned Infrastructure' },
]}
/>
<Hero {...pageContent.hero} /> <Hero {...pageContent.hero} />
<main id="main"> <main id="main">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

View File

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 865 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

View File

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB