feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* PipelineRun: what happens between a change and its release, shown as the test run it is.
|
||||
* A change breaks something elsewhere, the run catches it, the release is blocked, the fix
|
||||
* goes in, the release goes out. Output is illustrative, modelled on a real test runner.
|
||||
*/
|
||||
|
||||
type Suite = {
|
||||
readonly file: string;
|
||||
readonly tests: number;
|
||||
readonly ms: number;
|
||||
};
|
||||
|
||||
const SUITES: readonly Suite[] = [
|
||||
{ file: "auth/login.spec.ts", tests: 9, ms: 412 },
|
||||
{ file: "orders/create.spec.ts", tests: 12, ms: 688 },
|
||||
{ file: "orders/status.spec.ts", tests: 7, ms: 301 },
|
||||
{ file: "invoices/total.spec.ts", tests: 14, ms: 954 },
|
||||
{ file: "emails/confirmation.spec.ts", tests: 6, ms: 377 },
|
||||
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
|
||||
];
|
||||
|
||||
type Scenario = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly branch: string;
|
||||
readonly failingFile: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: readonly Scenario[] = [
|
||||
{
|
||||
id: "orders",
|
||||
label: "Orders",
|
||||
branch: "change/order-discounts",
|
||||
failingFile: "invoices/total.spec.ts",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Login",
|
||||
branch: "change/login-with-username",
|
||||
failingFile: "emails/confirmation.spec.ts",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
label: "Invoices",
|
||||
branch: "change/invoice-layout",
|
||||
failingFile: "emails/invoice-mail.spec.ts",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
{
|
||||
id: "emails",
|
||||
label: "Emails",
|
||||
branch: "change/email-queue",
|
||||
failingFile: "orders/status.spec.ts",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
];
|
||||
|
||||
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
|
||||
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
|
||||
|
||||
/**
|
||||
* The run as a sequence of frames. Each frame says how far the first attempt got,
|
||||
* and whether the fix has been pushed and how far the second attempt got.
|
||||
*/
|
||||
type Frame = {
|
||||
readonly attempt: 1 | 2;
|
||||
/** Stages finished so far (0..4). */
|
||||
readonly stagesDone: number;
|
||||
/** Suites reported so far within the Tests stage. */
|
||||
readonly suitesDone: number;
|
||||
};
|
||||
|
||||
const buildFrames = (): readonly Frame[] => {
|
||||
const frames: Frame[] = [];
|
||||
for (const attempt of [1, 2] as const) {
|
||||
frames.push({ attempt, stagesDone: 0, suitesDone: 0 });
|
||||
frames.push({ attempt, stagesDone: 1, suitesDone: 0 });
|
||||
frames.push({ attempt, stagesDone: 2, suitesDone: 0 });
|
||||
for (let s = 1; s <= SUITES.length; s++) {
|
||||
frames.push({ attempt, stagesDone: 2, suitesDone: s });
|
||||
}
|
||||
frames.push({ attempt, stagesDone: 3, suitesDone: SUITES.length });
|
||||
frames.push({ attempt, stagesDone: 4, suitesDone: SUITES.length });
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
const FRAMES = buildFrames();
|
||||
const FRAME_MS = 420;
|
||||
const HOLD_AFTER_FAIL_MS = 3200;
|
||||
const FIRST_ATTEMPT_END = FRAMES.findIndex((f) => f.attempt === 2) - 1;
|
||||
|
||||
type StageState = "waiting" | "running" | "passed" | "failed" | "blocked";
|
||||
|
||||
const STAGE_STYLE: Readonly<Record<StageState, string>> = {
|
||||
waiting: "border-slate-700 text-slate-500",
|
||||
running: "border-slate-400 text-white",
|
||||
passed: "border-emerald-500/60 text-emerald-400",
|
||||
failed: "border-rose-500/70 text-rose-400",
|
||||
blocked: "border-slate-700 text-slate-500 line-through",
|
||||
};
|
||||
|
||||
const STAGE_MARK: Readonly<Record<StageState, string>> = {
|
||||
waiting: "·",
|
||||
running: "…",
|
||||
passed: "✓",
|
||||
failed: "✕",
|
||||
blocked: "—",
|
||||
};
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const PipelineRun: React.FC = () => {
|
||||
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
|
||||
const [frameIndex, setFrameIndex] = React.useState(0);
|
||||
const [run, setRun] = React.useState(0);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
|
||||
const scenario =
|
||||
SCENARIOS.find((item) => item.id === scenarioId) ?? SCENARIOS[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.25 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!inView) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setFrameIndex(FRAMES.length - 1);
|
||||
return;
|
||||
}
|
||||
setFrameIndex(0);
|
||||
let index = 0;
|
||||
let timer = 0;
|
||||
const tick = () => {
|
||||
index += 1;
|
||||
if (index >= FRAMES.length) return;
|
||||
setFrameIndex(index);
|
||||
// Stay on the blocked release long enough to read the failure.
|
||||
const delay = index === FIRST_ATTEMPT_END ? HOLD_AFTER_FAIL_MS : FRAME_MS;
|
||||
timer = window.setTimeout(tick, delay);
|
||||
};
|
||||
timer = window.setTimeout(tick, FRAME_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [inView, scenarioId, run]);
|
||||
|
||||
if (!scenario) return null;
|
||||
|
||||
const frame = FRAMES[frameIndex] ?? FRAMES[0];
|
||||
if (!frame) return null;
|
||||
|
||||
const firstAttempt = frame.attempt === 1;
|
||||
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
|
||||
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
|
||||
const finished = frame.stagesDone === STAGES.length;
|
||||
|
||||
const stageState = (index: number): StageState => {
|
||||
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
|
||||
return "failed";
|
||||
}
|
||||
if (index === 3 && firstAttempt && frame.stagesDone >= 3 && failureSeen) {
|
||||
return finished ? "blocked" : "waiting";
|
||||
}
|
||||
if (index < frame.stagesDone) return "passed";
|
||||
if (index === frame.stagesDone) return "running";
|
||||
return "waiting";
|
||||
};
|
||||
|
||||
const failedCount = failureSeen ? 1 : 0;
|
||||
const reportedTests = SUITES.slice(0, frame.suitesDone).reduce(
|
||||
(sum, suite) => sum + suite.tests,
|
||||
0,
|
||||
);
|
||||
|
||||
// Which of the three explanations applies right now.
|
||||
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
|
||||
|
||||
const EXPLANATIONS = [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SCENARIOS.map((item) => {
|
||||
const selected = item.id === scenario.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
setScenarioId(item.id);
|
||||
setRun((n) => n + 1);
|
||||
}}
|
||||
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
|
||||
{EXPLANATIONS.map((item, index) => {
|
||||
const active = index === explain;
|
||||
return (
|
||||
<li
|
||||
key={item.title}
|
||||
className={`border-t py-5 transition-colors duration-300 ${
|
||||
active ? "border-slate-900" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`m-0 font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
|
||||
active ? "text-slate-900" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p
|
||||
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||
active ? "text-slate-600" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRun((n) => n + 1)}
|
||||
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
|
||||
>
|
||||
Run again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
|
||||
{/* Run header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex gap-1.5" aria-hidden="true">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
{scenario.branch}
|
||||
<span className="text-slate-600"> → </span>main
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
run #{frame.attempt === 1 ? 481 : 482}
|
||||
{frame.attempt === 2 && (
|
||||
<span className="text-slate-400"> · fix pushed</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
|
||||
{STAGES.map((stage, index) => {
|
||||
const state = stageState(index);
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
|
||||
>
|
||||
<span>{stage}</span>
|
||||
<span aria-hidden="true">{STAGE_MARK[state]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Test output */}
|
||||
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
|
||||
{frame.stagesDone < 2 && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
{frame.stagesDone === 0
|
||||
? "$ lint: checking the code style…"
|
||||
: "$ types: checking that the parts fit together…"}
|
||||
</p>
|
||||
)}
|
||||
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
|
||||
const failed = firstAttempt && index === failingIndex;
|
||||
return (
|
||||
<div key={suite.file}>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
|
||||
>
|
||||
{failed ? "FAIL" : "PASS"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-slate-200">
|
||||
{suite.file}
|
||||
</span>
|
||||
<span className="hidden text-slate-500 sm:inline">
|
||||
{suite.tests} tests
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-slate-600">
|
||||
{suite.ms} ms
|
||||
</span>
|
||||
</div>
|
||||
{failed && (
|
||||
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
|
||||
● {scenario.testName}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
|
||||
expected {scenario.expected}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
|
||||
received {scenario.received}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
running {SUITES[frame.suitesDone]?.file}…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
|
||||
<span>
|
||||
<span className="text-slate-500">Tests </span>
|
||||
{failedCount > 0 && (
|
||||
<span className="text-rose-400">{failedCount} failed, </span>
|
||||
)}
|
||||
<span className="text-emerald-400">
|
||||
{reportedTests - failedCount} passed
|
||||
</span>
|
||||
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
|
||||
!finished
|
||||
? "bg-slate-800 text-slate-400"
|
||||
: firstAttempt
|
||||
? "bg-rose-500/15 text-rose-400"
|
||||
: "bg-emerald-500/15 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{!finished
|
||||
? "release pending"
|
||||
: firstAttempt
|
||||
? "release blocked"
|
||||
: "released"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
|
||||
An illustration with made-up file names and numbers, modelled on a
|
||||
real test run. Tests do not catch every mistake, but they do catch
|
||||
this kind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user