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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm run test:ci
|
||||
14
AGENTS.md
@@ -10,7 +10,7 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
|
||||
## 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/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.
|
||||
- **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.
|
||||
@@ -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/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/beasties.mjs` | Post-build: inlines critical CSS into **every** `dist/*.html` (including 404 and 410). Resolves stylesheet URLs relative to `dist/`. |
|
||||
| `scripts/minify-static-js.mjs` | Post-build: minifies JS in `dist/assets/`. |
|
||||
| `scripts/copy-410-paths.mjs` | Run by `build-preview` only: copies `410.html` to each 410 URL path as `index.html` so static preview (e.g. `serve dist`) shows the 410 page at those URLs. Production uses `build` (no copy); nginx returns 410 via explicit location blocks and `error_page 410 /410.html`. |
|
||||
| `nginx.conf` | Serves static files; `try_files $uri $uri/ /index.html` for SPA-style fallback; `error_page 404 /404.html` and `error_page 410 /410.html` for custom error pages. |
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **Critical CSS**: The build runs Beasties on all `dist/*.html`. Beasties inlines critical CSS from linked stylesheets (including `/assets/error-pages.css`) into 404.html and 410.html. So:
|
||||
- **Do** keep the `<link rel="stylesheet" href="/assets/error-pages.css">` in 404.html and 410.html; Beasties will inline it at build time.
|
||||
- **Do not** add error-page-only CSS in `src/app.css`; the app bundle is not loaded on error pages.
|
||||
- **Theme alignment**: When changing light/dark colors or typography in `src/app.css`, update **`static/assets/error-pages.css`** so 404/410 stay visually consistent (same `--ep-*` tokens and, if needed, `@media (prefers-color-scheme: dark)`).
|
||||
- **Local fonts**: Error pages use the same font paths as the main site (`/assets/fonts/...`) via `@font-face` in `error-pages.css`. Do not use Google Fonts or other external font URLs on error pages.
|
||||
@@ -54,8 +54,8 @@ This file helps LLM agents work in this repo without introducing anti-patterns.
|
||||
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).
|
||||
4. **Skipping Beasties for new HTML**
|
||||
Any new `.html` in `static/` is copied to `dist/` and **must** be processed by Beasties (the script already runs on all `dist/*.html`). Do not add static HTML that bypasses the build or that uses only inline styles without a linked stylesheet (linked styles get inlined by Beasties).
|
||||
|
||||
5. **Diverging error page theme**
|
||||
Do not change 404/410 styling in a way that ignores `static/assets/error-pages.css` or that duplicates theme tokens from `src/app.css` in ad-hoc form. Keep one error-page stylesheet and align its variables with `app.css` when you change the main theme.
|
||||
@@ -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 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).
|
||||
- **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`).
|
||||
|
||||
@@ -34,10 +34,13 @@ This project uses **pnpm** as the package manager. After cloning, run `pnpm inst
|
||||
| `pnpm test` | Run unit tests (Vitest) |
|
||||
| `pnpm run test:e2e` | Run Playwright visual regression (e2e) |
|
||||
| `pnpm run test:e2e:update-snapshots` | Regenerate e2e snapshots (in devcontainer = CI; see Visual regression) |
|
||||
| `pnpm run test:ci` | Run same tests as CI: lint, lint:css, unit tests, e2e (used by pre-commit hook) |
|
||||
| `pnpm run lint` | ESLint (JS/TS/Svelte) |
|
||||
| `pnpm run lint:css` | Stylelint (global CSS + Svelte styles) |
|
||||
| `pnpm run format` | Prettier (JS/TS/Svelte/CSS/JSON) |
|
||||
|
||||
A **pre-commit hook** (Husky) runs `pnpm run test:ci` so the same tests as CI run before each commit. Skip with `git commit --no-verify` if needed.
|
||||
|
||||
### Option 1: pnpm dev (recommended for editing)
|
||||
|
||||
From the project root:
|
||||
@@ -88,7 +91,7 @@ E2e tests use Playwright and compare full-page screenshots to committed snapshot
|
||||
|
||||
**If you’re not using the devcontainer:** run the **update-e2e-snapshots** workflow manually in Woodpecker (requires a `git_push_token` secret), or run `pnpm run test:e2e:update-snapshots` on a host with Docker.
|
||||
|
||||
Local `pnpm run test:e2e` on **macOS** uses the Darwin snapshot; the Linux snapshot is used in CI and in the devcontainer.
|
||||
**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)
|
||||
|
||||
@@ -127,7 +130,7 @@ mifi-ventures-landing/
|
||||
├── svelte.config.js # SvelteKit config (adapter-static)
|
||||
├── vite.config.ts # Vite config
|
||||
├── 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)
|
||||
│ ├── favicon.svg, favicon.ico, robots.txt
|
||||
│ ├── copyright-year.js # Minimal client script (footer year)
|
||||
|
||||
29
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "mifi-ventures-landing",
|
||||
"version": "2.0.0",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"repository": "https://git.mifi.dev/mifi-ventures/landing.git",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"description": "mifi Ventures landing site — SvelteKit static build with critical CSS inlining",
|
||||
"type": "module",
|
||||
"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",
|
||||
"dev": "vite dev",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -20,26 +20,29 @@
|
||||
"preview": "serve dist -p 4173",
|
||||
"test": "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: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": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/gtag.js": "^0.0.20",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"critters": "^0.0.24",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.39.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"beasties": "^0.4.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^10.0.3",
|
||||
"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-preset-env": "^11.1.2",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -53,10 +56,10 @@
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^0.563.1"
|
||||
"@lucide/svelte": "^0.577.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
testDir: 'tests',
|
||||
testMatch: /.*\.spec\.(ts|js)/,
|
||||
// Use linux in snapshot paths so local (darwin) runs compare against the same snapshots as CI.
|
||||
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-linux{ext}',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
@@ -13,7 +15,10 @@ export default defineConfig({
|
||||
trace: 'on-first-retry',
|
||||
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
|
||||
? undefined
|
||||
: {
|
||||
|
||||
1427
pnpm-lock.yaml
generated
47
scripts/beasties.mjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Post-build: generate sitemap.xml from prerendered pages in dist/.
|
||||
* Scans for index.html (root and under each path), excludes 410 paths.
|
||||
* Run after vite build and 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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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.
|
||||
* Runs after vite build (and optionally after beasties). Uses esbuild for minification.
|
||||
*/
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
27
scripts/run-e2e-in-docker.sh
Executable 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
@@ -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
|
||||
146
src/app.css
@@ -490,144 +490,44 @@ a {
|
||||
top: 0.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-list--ordered {
|
||||
list-style: decimal;
|
||||
padding-left: var(--space-xl);
|
||||
}
|
||||
&.ordered {
|
||||
list-style: decimal;
|
||||
padding-left: var(--space-xl);
|
||||
|
||||
.content-list--ordered li {
|
||||
padding-left: var(--space-sm);
|
||||
}
|
||||
& li {
|
||||
padding-left: var(--space-sm);
|
||||
|
||||
.content-list--ordered li::before {
|
||||
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;
|
||||
&::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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-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 {
|
||||
margin: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
.engagements-list dt {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
margin-top: var(--space-lg);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
& dt {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
margin-top: var(--space-lg);
|
||||
margin-bottom: var(--space-xs);
|
||||
|
||||
.engagements-list dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.engagements-list dd {
|
||||
margin: 0 0 0 var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
& dd {
|
||||
margin: 0 0 0 var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
.services-intro {
|
||||
|
||||
61
src/lib/components/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
items = [],
|
||||
}: {
|
||||
items: BreadcrumbItem[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="section breadcrumbs" aria-label="Breadcrumbs">
|
||||
<div class="container">
|
||||
<ol class="list">
|
||||
{#each items as item, index}
|
||||
<li class="item">
|
||||
{#if item.href}
|
||||
<a href={item.href}>{item.label}</a>
|
||||
{#if index < items.length - 1}<span
|
||||
class="separator"
|
||||
aria-hidden="true">></span
|
||||
>{/if}
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
padding: var(--space-md) 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: var(--font-size-small);
|
||||
margin-inline: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
<p class="copyright">
|
||||
© <span id="copyright-year">2026</span> mifi Ventures, LLC. All rights reserved.
|
||||
</p>
|
||||
<nav class="footer-links footer-links--wrap" aria-label="Footer links">
|
||||
<nav class="footer-links footer-links-wrap" aria-label="Footer links">
|
||||
<a
|
||||
class="link"
|
||||
href="https://linkedin.com/in/the-mifi"
|
||||
@@ -72,7 +72,7 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.footer-links--wrap {
|
||||
.footer-links-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<section id={section.id} class={sectionClasses} aria-labelledby={headingId}>
|
||||
<div class={containerClass}>
|
||||
<h2 id={headingId} class:sr-only={section.headingSrOnly ?? false}>
|
||||
<h2 id={headingId} class={{ 'sr-only': section.headingSrOnly ?? false }}>
|
||||
{section.heading}
|
||||
</h2>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{/if}
|
||||
|
||||
{#if (section.orderedBullets?.length ?? 0) > 0}
|
||||
<ol class="content-list content-list--ordered">
|
||||
<ol class="content-list ordered">
|
||||
{#each section.orderedBullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
|
||||
@@ -6,25 +6,36 @@
|
||||
sectionId = 'services-grid',
|
||||
headingId = 'services-heading',
|
||||
heading = 'Services',
|
||||
overview = '',
|
||||
surface = 'bg',
|
||||
}: {
|
||||
services: ServiceCard[];
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
heading?: string;
|
||||
overview?: string;
|
||||
surface?: 'bg' | 'bg-alt';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id={sectionId} class="section services-grid-section" aria-labelledby={headingId}>
|
||||
<section
|
||||
id={sectionId}
|
||||
class={['section services-grid-section', { 'bg-alt': surface === 'bg-alt' }]}
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div class="container">
|
||||
<h2 id={headingId} class="section-title">{heading}</h2>
|
||||
{#if overview}
|
||||
<p class="overview">{overview}</p>
|
||||
{/if}
|
||||
<ul class="services-card-list">
|
||||
{#each services as service (service.href)}
|
||||
<li class="services-card">
|
||||
<h3 class="services-card__title">{service.title}</h3>
|
||||
<p class="services-card__desc">{service.description}</p>
|
||||
<li class={['services-card', { bg: surface === 'bg-alt' }]}>
|
||||
<h3 class="title">{service.title}</h3>
|
||||
<p class="desc">{service.description}</p>
|
||||
<a
|
||||
href={service.href}
|
||||
class="services-card__link"
|
||||
class="link"
|
||||
data-umami-event="service link"
|
||||
data-umami-event-label={service.href}
|
||||
>
|
||||
@@ -36,3 +47,69 @@
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.services-grid-section {
|
||||
background-color: var(--color-bg);
|
||||
|
||||
&.bg-alt {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
}
|
||||
|
||||
.overview {
|
||||
max-width: var(--max-text-width);
|
||||
margin: 0 auto var(--space-xxl) auto;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.services-card-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.services-card {
|
||||
padding: var(--space-xl);
|
||||
background-color: var(--color-bg-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.bg {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1;
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
& span {
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<section id="who-its-for" class="section" aria-labelledby="who-for-heading">
|
||||
<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-block">
|
||||
<h3 id="who-for-list-heading" class="list-heading">{whoForHeading}</h3>
|
||||
@@ -51,6 +51,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.list-heading {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
@@ -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>
|
||||
26
src/lib/data/home/services.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const services = [
|
||||
{
|
||||
title: 'Hands-On SaaS Architecture',
|
||||
description:
|
||||
'Build the foundations that let SaaS products evolve without accumulating structural debt.',
|
||||
href: '/services/hands-on-saas-architecture-consultant',
|
||||
},
|
||||
{
|
||||
title: 'MVP Architecture & Launch',
|
||||
description:
|
||||
'Ship quickly without creating a frontend mess or fragile product foundation.',
|
||||
href: '/services/mvp-architecture-and-launch',
|
||||
},
|
||||
{
|
||||
title: 'Fractional CTO / Technical Partner',
|
||||
description:
|
||||
'Technical leadership for teams that need architectural direction without hiring a full-time CTO.',
|
||||
href: '/services/fractional-cto-for-early-stage-saas',
|
||||
},
|
||||
{
|
||||
title: 'Stage-Aligned Infrastructure',
|
||||
description:
|
||||
"Infrastructure decisions that match your company's stage, without unnecessary SaaS sprawl or cloud complexity.",
|
||||
href: '/services/stage-aligned-infrastructure',
|
||||
},
|
||||
];
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import Hero from '$lib/components/home/Hero.svelte';
|
||||
import ServicesOverview from '$lib/components/home/ServicesOverview.svelte';
|
||||
import HomeHero from '$lib/components/home/Hero.svelte';
|
||||
import ExperienceSection from '$lib/components/home/ExperienceSection.svelte';
|
||||
import WhatWeDo from '$lib/components/home/WhatWeDo.svelte';
|
||||
import ImpactSection from '$lib/components/home/ImpactSection.svelte';
|
||||
@@ -9,12 +8,14 @@
|
||||
import EngagementsSection from '$lib/components/home/EngagementsSection.svelte';
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
import { items as navigationItems } from '$lib/data/home/navigation';
|
||||
import ServicesCardGrid from '$lib/components/ServicesCardGrid.svelte';
|
||||
import { services } from '$lib/data/home/services';
|
||||
</script>
|
||||
|
||||
<Navigation items={navigationItems} page="home" />
|
||||
<Hero />
|
||||
<HomeHero />
|
||||
<main id="main">
|
||||
<ServicesOverview />
|
||||
<ServicesCardGrid {services} surface="bg-alt" />
|
||||
<ExperienceSection />
|
||||
<WhatWeDo />
|
||||
<ImpactSection />
|
||||
|
||||
@@ -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–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 1–2 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>1–10 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>
|
||||
@@ -8,10 +8,18 @@
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
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 Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
|
||||
<main id="main">
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
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 Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
|
||||
<main id="main">
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
import { pageContent } from '$lib/data/services/mvp-architecture-and-launch/content';
|
||||
import { faqItems } from '$lib/data/services/mvp-architecture-and-launch/faq';
|
||||
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
||||
</script>
|
||||
|
||||
<Navigation items={pageContent.navItems} page="mvp-architecture-and-launch" />
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Services', href: '/services' },
|
||||
{ label: 'MVP Architecture & Launch' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Hero {...pageContent.hero} />
|
||||
|
||||
<main id="main">
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
import ScheduleSection from '$lib/components/ScheduleSection.svelte';
|
||||
import { pageContent } from '$lib/data/services/stage-aligned-infrastructure/content';
|
||||
import { faqItems } from '$lib/data/services/stage-aligned-infrastructure/faq';
|
||||
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
||||
</script>
|
||||
|
||||
<Navigation items={pageContent.navItems} page="stage-aligned-infrastructure" />
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Services', href: '/services' },
|
||||
{ label: 'Stage-Aligned Infrastructure' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Hero {...pageContent.hero} />
|
||||
|
||||
<main id="main">
|
||||
|
||||
|
Before Width: | Height: | Size: 580 KiB |
BIN
tests/visual.spec.ts-snapshots/home-chromium-desktop-linux.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 554 KiB |
BIN
tests/visual.spec.ts-snapshots/home-chromium-mobile-linux.png
Normal file
|
After Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
Before Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
Before Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 911 KiB |
|
Before Width: | Height: | Size: 901 KiB |
|
After Width: | Height: | Size: 918 KiB |
|
Before Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 682 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 870 KiB |
|
Before Width: | Height: | Size: 865 KiB |
|
After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 351 KiB |