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:
2026-10-03 22:33:06 +02:00
co-authored by Claude Opus 5.5
parent 4eb663f42a
commit fa40fefe2f
781 changed files with 4925 additions and 55137 deletions
@@ -1,28 +0,0 @@
"use client";
import * as React from "react";
import { Pill } from "@/src/components/site";
import { AVAILABILITY_CONFIG } from "@/src/content/site";
import {
formatAvailability,
resolveAvailability,
} from "@/src/domain/availability";
const todayIso = (): string => new Date().toISOString().slice(0, 10);
/** Computes "today" in the browser so a statically rendered page never shows stale availability. */
export const AvailabilityPill: React.FC = () => {
const [today, setToday] = React.useState<string | null>(null);
React.useEffect(() => {
setToday(todayIso());
}, []);
if (today === null) return null;
return (
<Pill>
{formatAvailability(resolveAvailability(AVAILABILITY_CONFIG, today))}
</Pill>
);
};
@@ -0,0 +1,385 @@
"use client";
import * as React from "react";
/**
* BitHeadline: a headline whose letters are made of running binary digits.
*
* - "solid" lines condense from bits into massive dark type, with digits still moving inside.
* - "bits" lines stay built from digits only, so they read lighter.
*
* The real text stays in the DOM (transparent once the canvas is drawn), so it remains
* selectable, accessible and visible without JavaScript.
*/
export type BitLine = {
readonly text: string;
readonly tone: "solid" | "bits";
};
type Measured = {
readonly text: string;
readonly tone: BitLine["tone"];
readonly x: number;
readonly baseline: number;
/** Vertical extent of the glyphs, which can exceed a tight line box. */
readonly top: number;
readonly height: number;
readonly width: number;
readonly font: string;
readonly letterSpacing: string;
};
const DARK = "15, 23, 42"; // slate-900
const LIGHT = "255, 255, 255";
const INTRO_MS = 1600;
const FRAME_MS = 1000 / 30;
const POINTER_RADIUS = 170;
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
const makeSprite = (
digit: string,
color: string,
cell: number,
dpr: number,
): HTMLCanvasElement => {
const sprite = document.createElement("canvas");
sprite.width = Math.ceil(cell * dpr);
sprite.height = Math.ceil(cell * dpr);
const sctx = sprite.getContext("2d");
if (sctx) {
sctx.scale(dpr, dpr);
const mono =
getComputedStyle(document.documentElement)
.getPropertyValue("--font-jetbrains-mono")
.trim() || "ui-monospace";
sctx.font = `700 ${Math.round(cell * 1.15)}px ${mono}, ui-monospace, Menlo, monospace`;
sctx.textAlign = "center";
sctx.textBaseline = "middle";
sctx.fillStyle = color;
sctx.fillText(digit, cell / 2, cell / 2 + 0.5);
}
return sprite;
};
export const BitHeadline: React.FC<{
lines: readonly BitLine[];
className?: string;
/** Render as this heading level. */
as?: "h1" | "h2";
/** Light type on a dark surface. */
invert?: boolean;
}> = ({ lines, className = "", as: Tag = "h1", invert = false }) => {
const wrapRef = React.useRef<HTMLHeadingElement>(null);
const INK = invert ? LIGHT : DARK;
const PAPER = invert ? DARK : LIGHT;
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [painted, setPainted] = React.useState(false);
React.useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!wrap || !canvas || !ctx) return;
const off = document.createElement("canvas");
const offCtx = off.getContext("2d");
if (!offCtx) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let measured: Measured[] = [];
let width = 0;
let height = 0;
let dpr = 1;
let cell = 12;
let wash = 0.07;
let cols = 0;
let rows = 0;
let bits = new Uint8Array(0);
let phase = new Float32Array(0);
let speed = new Float32Array(0);
let sprites: {
light: HTMLCanvasElement[];
dark: HTMLCanvasElement[];
} | null = null;
let raf = 0;
let startedAt = 0;
let lastFrame = 0;
let pointer: { x: number; y: number } | null = null;
let disposed = false;
const measure = () => {
const box = wrap.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = box.width;
height = box.height;
for (const target of [canvas, off]) {
target.width = Math.ceil(width * dpr);
target.height = Math.ceil(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
const spans = Array.from(
wrap.querySelectorAll<HTMLSpanElement>("[data-bit-line]"),
);
measured = spans.map((span, index) => {
const rect = span.getBoundingClientRect();
const style = getComputedStyle(span);
const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
ctx.font = font;
const metrics = ctx.measureText("Hg");
const ascent = metrics.fontBoundingBoxAscent;
const descent = metrics.fontBoundingBoxDescent;
const lineTop = rect.top - box.top;
const baseline =
lineTop + (rect.height - (ascent + descent)) / 2 + ascent;
return {
text: span.textContent ?? "",
tone: lines[index]?.tone ?? "solid",
x: rect.left - box.left,
top: baseline - ascent,
height: ascent + descent,
width: rect.width,
baseline,
font,
letterSpacing: style.letterSpacing,
};
});
const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100;
cell = Math.round(Math.min(9, Math.max(4, fontSize / 19)));
// Small type needs a stronger wash under the digits to keep its shape.
wash = fontSize < 100 ? 0.2 : 0.07;
cols = Math.ceil(width / cell);
rows = Math.ceil(height / cell);
bits = new Uint8Array(cols * rows);
phase = new Float32Array(cols * rows);
speed = new Float32Array(cols * rows);
for (let i = 0; i < bits.length; i++) {
bits[i] = Math.random() < 0.5 ? 0 : 1;
phase[i] = Math.random() * Math.PI * 2;
speed[i] = 0.6 + Math.random() * 1.8;
}
sprites = {
light: [
makeSprite("0", `rgb(${PAPER})`, cell, dpr),
makeSprite("1", `rgb(${PAPER})`, cell, dpr),
],
dark: [
makeSprite("0", `rgb(${INK})`, cell, dpr),
makeSprite("1", `rgb(${INK})`, cell, dpr),
],
};
};
const setFont = (context: CanvasRenderingContext2D, line: Measured) => {
context.font = line.font;
context.textBaseline = "alphabetic";
context.textAlign = "left";
if ("letterSpacing" in context) {
(
context as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing =
line.letterSpacing === "normal" ? "0px" : line.letterSpacing;
}
};
/** Digits over one line's box. `strength` scales the alpha, `set` picks the sprite color. */
const drawDigits = (
context: CanvasRenderingContext2D,
line: Measured,
set: HTMLCanvasElement[],
seconds: number,
base: number,
swing: number,
) => {
const firstRow = Math.max(0, Math.floor(line.top / cell));
const lastRow = Math.min(
rows,
Math.ceil((line.top + line.height) / cell),
);
const firstCol = Math.max(0, Math.floor(line.x / cell));
const lastCol = Math.min(cols, Math.ceil((line.x + line.width) / cell));
// A soft diagonal scan band that travels across the headline.
const scan = ((seconds * 0.16) % 1.6) * (width + height) - height * 0.3;
for (let row = firstRow; row < lastRow; row++) {
for (let col = firstCol; col < lastCol; col++) {
const i = row * cols + col;
const x = col * cell;
const y = row * cell;
let alpha =
base +
swing *
(0.5 +
0.5 * Math.sin(seconds * (speed[i] ?? 1) + (phase[i] ?? 0)));
const scanDistance = Math.abs(x + y - scan);
if (scanDistance < 90) alpha += (1 - scanDistance / 90) * 0.45;
if (pointer) {
const d = Math.hypot(x - pointer.x, y - pointer.y);
if (d < POINTER_RADIUS) alpha += (1 - d / POINTER_RADIUS) * 0.7;
}
if (alpha <= 0.02) continue;
// Occasionally flip a bit so the field stays alive.
if (Math.random() < 0.004) bits[i] = bits[i] === 1 ? 0 : 1;
const sprite = set[bits[i] ?? 0];
if (!sprite) continue;
context.globalAlpha = clamp01(alpha);
context.drawImage(sprite, x, y, cell, cell);
}
}
context.globalAlpha = 1;
};
const paint = (now: number) => {
if (!sprites) return;
const seconds = now / 1000;
const intro = reducedMotion
? 1
: easeOut(clamp01((now - startedAt) / INTRO_MS));
ctx.clearRect(0, 0, width, height);
for (const line of measured) {
offCtx.globalCompositeOperation = "source-over";
offCtx.clearRect(0, 0, width, height);
if (line.tone === "solid") {
// Letters start as pure bits and condense into solid ink.
drawDigits(
offCtx,
line,
sprites.dark,
seconds,
0.75 * (1 - intro),
0.25 * (1 - intro),
);
offCtx.globalCompositeOperation = "destination-in";
setFont(offCtx, line);
offCtx.fillStyle = "#000";
offCtx.fillText(line.text, line.x, line.baseline);
ctx.drawImage(off, 0, 0, width, height);
setFont(ctx, line);
ctx.fillStyle = `rgba(${INK}, ${intro})`;
ctx.fillText(line.text, line.x, line.baseline);
// Bright digits keep moving inside the solid letters.
offCtx.globalCompositeOperation = "source-over";
offCtx.clearRect(0, 0, width, height);
drawDigits(
offCtx,
line,
sprites.light,
seconds,
0.02 * intro,
0.16 * intro,
);
offCtx.globalCompositeOperation = "destination-in";
setFont(offCtx, line);
offCtx.fillText(line.text, line.x, line.baseline);
ctx.drawImage(off, 0, 0, width, height);
} else {
// Stays built from digits only, on a faint wash so the letters keep their shape.
setFont(ctx, line);
ctx.fillStyle = `rgba(${INK}, ${wash * intro})`;
ctx.fillText(line.text, line.x, line.baseline);
drawDigits(offCtx, line, sprites.dark, seconds, 0.3, 0.5);
offCtx.globalCompositeOperation = "destination-in";
setFont(offCtx, line);
offCtx.fillStyle = "#000";
offCtx.fillText(line.text, line.x, line.baseline);
ctx.globalAlpha = 0.25 + 0.75 * intro;
ctx.drawImage(off, 0, 0, width, height);
ctx.globalAlpha = 1;
}
}
};
const loop = (now: number) => {
if (disposed) return;
if (now - lastFrame >= FRAME_MS) {
lastFrame = now;
paint(now);
}
raf = requestAnimationFrame(loop);
};
const restart = () => {
cancelAnimationFrame(raf);
measure();
// Hidden at this breakpoint (display: none): nothing to draw.
if (width === 0 || height === 0) return;
if (reducedMotion) {
paint(performance.now());
return;
}
raf = requestAnimationFrame(loop);
};
const onPointerMove = (event: PointerEvent) => {
const box = wrap.getBoundingClientRect();
pointer = { x: event.clientX - box.left, y: event.clientY - box.top };
};
const onPointerLeave = () => {
pointer = null;
};
const resizeObserver = new ResizeObserver(() => restart());
document.fonts.ready.then(() => {
if (disposed) return;
startedAt = performance.now();
restart();
setPainted(true);
resizeObserver.observe(wrap);
if (!reducedMotion) {
window.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerleave", onPointerLeave);
}
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
window.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerleave", onPointerLeave);
};
}, [lines, INK, PAPER, invert]);
return (
<Tag ref={wrapRef} className={`relative m-0 ${className}`}>
{lines.map((line) => (
<span
key={line.text}
data-bit-line
className={`block w-fit whitespace-nowrap transition-colors duration-300 ${
painted
? "text-transparent"
: line.tone === "solid"
? invert
? "text-white"
: "text-slate-900"
: invert
? "text-slate-600"
: "text-slate-300"
}`}
>
{line.text}
</span>
))}
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
</Tag>
);
};
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
interface BlockProps {
/** Two-digit section index, shown with its binary form. */
index: string;
label: string;
title: React.ReactNode;
id?: string;
children: React.ReactNode;
}
/**
* Block: one section of the homepage. A single left axis, one kicker line,
* one oversized headline, then the content. No sidebars.
*/
export const Block: React.FC<BlockProps> = ({
index,
label,
title,
id,
children,
}) => (
<section
id={id}
className="relative scroll-mt-24 border-t border-slate-200 bg-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-slate-900">
{index}
</span>
<span className="h-px w-10 bg-slate-300" />
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
{label}
</span>
<span
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
aria-hidden="true"
>
{parseInt(index, 10).toString(2).padStart(8, "0")}
</span>
</div>
</Reveal>
<Reveal delay={0.1}>
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
</Reveal>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
@@ -1,47 +0,0 @@
import { Container } from "@/src/components/site";
type Reference = {
readonly name: string;
readonly href: string;
readonly built: string;
};
const REFERENCES: readonly Reference[] = [
{
name: "klz-cables.com",
href: "https://klz-cables.com",
built:
"multilingual (DE/EN) Next.js site with a headless CMS and generated PDF datasheets",
},
{
name: "e-tib.com",
href: "https://e-tib.com",
built:
"multilingual Next.js site with structured data, automated QA and CI/CD",
},
];
export const BuiltAndRunning: React.FC = () => (
<section className="border-b border-border py-10 md:py-14">
<Container>
<p className="!mb-0 text-sm leading-relaxed text-muted md:text-base">
<span className="mr-2 font-mono text-xs uppercase tracking-widest text-accent">
Built and running
</span>
{REFERENCES.map((ref, index) => (
<span key={ref.name}>
{index > 0 ? " · " : null}
<a
href={ref.href}
rel="noopener"
className="underline underline-offset-4 hover:text-accent"
>
{ref.name}
</a>{" "}
{ref.built}
</span>
))}
</p>
</Container>
</section>
);
@@ -1,35 +0,0 @@
import { Container, SectionHeader } from "@/src/components/site";
import { ENGAGEMENT_BLOCKS } from "@/src/content/site";
export const CapacityBlocks: React.FC = () => (
<section className="border-b border-border py-14 md:py-20">
<Container>
<SectionHeader
kicker="capacity blocks"
title="Book weekly capacity"
lead="You reserve hours per week for a month at a time. No fixed-scope surprises."
/>
<ul className="!m-0 mt-10 grid list-none gap-4 !p-0 md:grid-cols-3">
{ENGAGEMENT_BLOCKS.map((block) => (
<li
key={block.name}
className="!mb-0 rounded-lg border border-border bg-surface p-5"
>
<h3 className="!mb-1 !mt-0 !text-lg !leading-snug md:!text-xl lg:!text-xl">
{block.name}
</h3>
<p className="!mb-4 font-mono text-sm text-muted">
{block.hoursPerWeek}
</p>
<p className="!mb-0 font-mono text-lg font-semibold text-fg">
{block.pricePerMonth}
</p>
</li>
))}
</ul>
<p className="!mb-0 mt-6 text-sm text-muted">
2-week notice, transparent time tracking, no fixed-scope surprises.
</p>
</Container>
</section>
);
@@ -0,0 +1,35 @@
import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitHeadline, type BitLine } from "./BitHeadline";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address, no forms, no calendar. */
export const ClosingCta: React.FC = () => (
<section
id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-24 md:px-12 md:py-44">
<Reveal>
<BitHeadline
as="h2"
invert
lines={CLOSING}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] xl:text-[12rem]"
/>
<p className="m-0 mt-10 max-w-2xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Write me a few lines about what you need. We then arrange a short
call.
</p>
<div className="mt-12">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-3xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-6xl" />
</div>
</Reveal>
</div>
</section>
);
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
const QUESTIONS = [
{
q: "I am not technical. Is that a problem?",
a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
},
{
q: "How long am I committed?",
a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
},
{
q: "Who owns what you build?",
a: "You do. Code and design belong to you, and you can hand them to another developer.",
},
{
q: "Can you work with our existing team and code?",
a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
},
{
q: "How do I reach you?",
a: "By email or video call. I work remotely and am available during European business hours.",
},
{
q: "What do you not do?",
a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
},
] as const;
export const Faq: React.FC = () => (
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
{QUESTIONS.map((item) => (
<details
key={item.q}
className="group border-t border-slate-200 py-6 open:pb-8"
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
{item.q}
<span
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
aria-hidden="true"
>
+
</span>
</summary>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{item.a}
</p>
</details>
))}
</div>
);
-20
View File
@@ -1,20 +0,0 @@
import { ButtonLink, Container } from "@/src/components/site";
export const FinalCta: React.FC = () => (
<section className="py-16 md:py-24">
<Container>
<h2 className="!mb-4 !mt-0 !text-3xl !leading-tight md:!text-4xl">
Tell me what you are building
</h2>
<p className="!mb-8 max-w-xl text-base text-muted md:text-lg">
A 20 minute call is enough to see whether a capacity block fits.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<ButtonLink href="/book">Book a 20 min call</ButtonLink>
<ButtonLink href="/notes" variant="secondary">
Read the notes
</ButtonLink>
</div>
</Container>
</section>
);
-58
View File
@@ -1,58 +0,0 @@
import { ButtonLink, Container, TerminalCard } from "@/src/components/site";
import { STACK } from "@/src/content/site";
import { AvailabilityPill } from "./AvailabilityPill";
const REPORT_LINES: readonly { label: string; value: string }[] = [
{ label: "spec", value: "12 cases written before code" },
{ label: "red", value: "12 failing for the right reason" },
{ label: "green", value: "12 passing" },
{ label: "mutation", value: "changed files checked, survivors fixed" },
{ label: "coverage", value: "changed lines reviewed for gaps" },
];
export const Hero: React.FC = () => (
<section className="border-b border-border py-14 md:py-24">
<Container>
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
<div>
<div className="mb-6 min-h-7">
<AvailabilityPill />
</div>
<h1 className="!mb-6 !text-4xl !leading-[1.1] md:!text-6xl">
AI makes code cheap.
<br />
<span className="text-accent">I make it correct.</span>
</h1>
<p className="!mb-8 max-w-xl text-base leading-relaxed text-muted md:text-lg">
Senior engineer for teams that build fast with AI and don&apos;t
want chaos. Spec-first, test-driven, mutation-checked. Booked in
weekly capacity blocks.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<ButtonLink href="/book">Book a 20 min call</ButtonLink>
<ButtonLink href="/process" variant="secondary">
See how I ship
</ButtonLink>
</div>
</div>
<TerminalCard title="delivery-report (illustrative example)">
<ul className="!m-0 list-none !p-0">
{REPORT_LINES.map((line) => (
<li key={line.label} className="!mb-1 flex gap-3">
<span className="w-20 shrink-0 text-accent">{line.label}</span>
<span className="text-muted">{line.value}</span>
</li>
))}
</ul>
<p className="!mb-0 !mt-4 text-xs text-faint">
Example format. Every real delivery lists the exact commands and
their results.
</p>
</TerminalCard>
</div>
<p className="!mb-0 mt-12 font-mono text-xs text-faint md:text-sm">
{STACK.join(" · ")}
</p>
</Container>
</section>
);
@@ -0,0 +1,85 @@
import * as React from "react";
import { Button } from "@/src/components/Button";
import { AbstractCircuit } from "@/src/components/Effects";
import { BitHeadline, type BitLine } from "./BitHeadline";
const HEADLINE: readonly BitLine[] = [
{ text: "WEB APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
];
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
export const HeroStatement: React.FC = () => (
<section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70">
<AbstractCircuit />
</div>
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
<BitHeadline
lines={HEADLINE}
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
/>
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
Freelance developer with 15 years of experience.{" "}
<span className="text-slate-900">
I build web apps, interfaces and websites.
</span>
</p>
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
<div
id="hero-cta"
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
>
<Button href="#contact" size="large" className="w-full sm:w-auto">
Get in touch
</Button>
<Button
href="#hello"
variant="outline"
size="large"
className="w-full sm:w-auto"
>
What I do
</Button>
</div>
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{TRUST.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
>
{item}
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
Built and running:{" "}
<a
href="https://klz-cables.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
klz-cables.com
</a>
{" · "}
<a
href="https://e-tib.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
e-tib.com
</a>
</li>
</ul>
</div>
</section>
);
+1 -1
View File
@@ -6,7 +6,7 @@ const STRUCTURED_DATA = {
name: SITE_NAME,
url: SITE_URL,
description:
"Senior freelance engineer for teams that build fast with AI: web apps, AI-code rescue, automation and Web3 frontends.",
"Senior freelance developer for web apps, interfaces and websites: weekly capacity for product teams, making unreliable apps dependable, and websites built end to end.",
founder: { "@type": "Person", name: SITE_NAME },
} as const;
-61
View File
@@ -1,61 +0,0 @@
import Link from "next/link";
import { Container, SectionHeader } from "@/src/components/site";
const STEPS: readonly { name: string; text: string }[] = [
{
name: "Spec first",
text: "The behavior is written down as concrete cases before any code.",
},
{
name: "Red",
text: "The tests run and fail for the right reason: missing behavior.",
},
{
name: "Green",
text: "The simplest clean implementation that makes them pass.",
},
{
name: "Mutation check",
text: "Mutation testing on the changed code shows whether the tests really catch bugs.",
},
{
name: "Delivery report",
text: "Every pull request lists the exact commands that were run and their results.",
},
];
export const HowIShip: React.FC = () => (
<section className="border-b border-border py-14 md:py-20">
<Container>
<SectionHeader
kicker="how I ship"
title="Fast with AI, checked by tests"
lead="AI writes the first draft. These five steps decide whether it ships."
/>
<ol className="!m-0 mt-10 grid list-none gap-4 !p-0 md:grid-cols-5">
{STEPS.map((step, index) => (
<li
key={step.name}
className="!mb-0 rounded-lg border border-border bg-surface p-4"
>
<p className="!mb-2 font-mono text-xs text-accent">
{String(index + 1).padStart(2, "0")}
</p>
<h3 className="!mb-2 !mt-0 !text-base !leading-snug md:!text-base lg:!text-base">
{step.name}
</h3>
<p className="!mb-0 text-sm text-muted">{step.text}</p>
</li>
))}
</ol>
<p className="!mb-0 mt-6 text-sm">
<Link
href="/process"
className="underline underline-offset-4 hover:text-accent"
>
Read the full process and a sample report
</Link>
</p>
</Container>
</section>
);
+266
View File
@@ -0,0 +1,266 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
/**
* PathStory: the four steps of one path, each with a drawing that explains it.
* The steps advance on their own until the visitor picks one.
*/
const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
path,
active,
}) => {
const [step, setStep] = React.useState(0);
const [auto, setAuto] = React.useState(true);
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0.35 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!active || !auto || !inView) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(
() => setStep((current) => (current + 1) % path.steps.length),
STEP_MS,
);
return () => window.clearInterval(timer);
}, [active, auto, inView, path.steps.length]);
const current = path.steps[step];
return (
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
<div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
{current?.title}
</span>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
</div>
<div className="px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={current ? `${current.title}. ${current.text}` : path.need}
/>
</div>
</div>
</div>
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
{path.steps.map((item, index) => {
const selected = index === step;
return (
<li key={item.title}>
<button
type="button"
aria-current={selected ? "step" : undefined}
onClick={() => {
setStep(index);
setAuto(false);
}}
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
selected ? "border-slate-900" : "border-slate-200"
}`}
>
{selected && auto && inView && (
<motion.span
key={`${path.id}-${index}`}
className="absolute left-0 top-[-1px] h-px bg-slate-900"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: STEP_MS / 1000, ease: "linear" }}
aria-hidden="true"
/>
)}
<span
className={`w-8 shrink-0 font-sans text-3xl font-black leading-none tracking-tighter transition-colors duration-300 md:text-4xl ${
selected ? "text-slate-900" : "text-slate-200"
}`}
>
{index + 1}
</span>
<span>
<span
className={`block font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
selected ? "text-slate-900" : "text-slate-500"
}`}
>
{item.title}
</span>
<span
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
selected ? "text-slate-600" : "text-slate-400"
}`}
>
{item.text}
</span>
</span>
</button>
</li>
);
})}
</ol>
</div>
);
};
/**
* PathPicker: the visitor says what they need and gets the answer for exactly that:
* what they get, how it runs, what it costs, what to do next.
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label="What do you need?"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
>
{SERVICE_PATHS.map((path, index) => {
const selected = path.id === activeId;
return (
<button
key={path.id}
type="button"
role="tab"
id={`path-tab-${path.id}`}
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
className={`group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
}`}
>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
</button>
);
})}
</div>
{SERVICE_PATHS.map((path) => {
const selected = path.id === activeId;
return (
<div
key={path.id}
role="tabpanel"
id={`path-panel-${path.id}`}
aria-labelledby={`path-tab-${path.id}`}
hidden={!selected}
className="mt-12 md:mt-20"
>
<motion.div
key={selected ? "on" : "off"}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<h3 className="m-0 font-sans text-3xl font-bold leading-[1.05] tracking-tighter text-slate-900 md:text-5xl">
{path.headline}
</h3>
<p className="m-0 mt-6 max-w-2xl font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{path.intro}
</p>
</div>
<div className="md:col-span-5">
<p className={`${MONO} m-0`}>What you get</p>
<ul className="m-0 mt-4 list-none p-0">
{path.youGet.map((item) => (
<li
key={item}
className="flex gap-4 border-t border-slate-200 py-4 font-sans text-base text-slate-900 md:text-lg"
>
<span
className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-slate-900"
aria-hidden="true"
/>
{item}
</li>
))}
</ul>
</div>
</div>
<div className="mt-14 md:mt-24">
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>What it costs</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{path.pricingModel}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{path.pricingText}
</p>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
{path.prices.map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{price.amount}
</p>
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
</div>
))}
</div>
</div>
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
{path.smallPrint}
</p>
<Button href="#contact" size="large">
{path.cta}
</Button>
</div>
</div>
</motion.div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,68 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
/**
* PortraitSection: the introduction, with the portrait fading out of a dark surface
* and the text set over it.
*/
export const PortraitSection: React.FC<{
id?: string;
index: string;
label: string;
title: React.ReactNode;
children: React.ReactNode;
}> = ({ id, index, label, title, children }) => (
<section
id={id}
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
>
{/* Portrait: right side on wide screens, top on small ones. */}
<motion.div
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
initial={{ opacity: 0, scale: 1.06 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
>
<Image
src="/marc-mintel.png"
alt="Portrait of Marc Mintel"
fill
sizes="(min-width: 768px) 62vw, 100vw"
className="object-cover object-[50%_18%] grayscale"
priority={false}
/>
{/* Fades: into the surface on the left, and at top and bottom. */}
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-slate-900 to-transparent" />
</motion.div>
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
<motion.div
className="-mt-24 max-w-3xl md:mt-0"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-white">
{index}
</span>
<span className="h-px w-10 bg-slate-600" />
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
{label}
</span>
</div>
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
<div className="mt-10 md:mt-14">{children}</div>
</motion.div>
</div>
</section>
);
@@ -1,23 +0,0 @@
import { Container, Stat } from "@/src/components/site";
const FACTS: readonly { value: string; label: string }[] = [
{ value: "15 years", label: "in web, apprenticeship from 2011" },
{ value: "Team lead", label: "frontend team lead at an agency" },
{ value: "Since 2021", label: "working fully remote" },
{
value: "7 chains",
label: "Rust + Solidity DeFi system with a zero-survivor mutation gate",
},
];
export const ProofStrip: React.FC = () => (
<section aria-label="Background" className="border-b border-border py-10">
<Container>
<div className="grid grid-cols-2 gap-8 lg:grid-cols-4">
{FACTS.map((fact) => (
<Stat key={fact.value} value={fact.value} label={fact.label} />
))}
</div>
</Container>
</section>
);
@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
/**
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
* already carries the same button) that appears once the hero call-to-action has
* scrolled away, and steps aside again when the closing section is in view.
*/
export const StickyBook: React.FC = () => {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const hero = document.getElementById("hero-cta");
const closing = document.getElementById("contact");
if (!hero || !closing) return;
const seen = new Map<Element, boolean>();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) seen.set(entry.target, entry.isIntersecting);
setVisible(!seen.get(hero) && !seen.get(closing));
});
observer.observe(hero);
observer.observe(closing);
return () => observer.disconnect();
}, []);
return (
<div
className={`fixed inset-x-0 bottom-6 z-[90] hidden justify-center px-4 transition-all duration-500 md:flex ${
visible
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-6 opacity-0"
}`}
aria-hidden={!visible}
>
<a
href="#contact"
tabIndex={visible ? 0 : -1}
className="flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
>
Get in touch <span aria-hidden="true">→</span>
</a>
</div>
);
};
@@ -1,33 +0,0 @@
import { Container, SectionHeader } from "@/src/components/site";
const SPRINT_POINTS: readonly string[] = [
"Time-boxed sprint with a clear scope list, from 3,800 EUR. Extra work is a follow-up block, not open scope.",
"Static Next.js site: no CMS and no server for you to run.",
"Hosting, SSL and domain renewal are included in a flat monthly plan.",
"Changes by email, no CMS training.",
];
export const WebsitesSection: React.FC = () => (
<section className="border-b border-border py-14 md:py-20">
<Container>
<SectionHeader
kicker="websites"
title="Websites, handled end to end"
lead="For clients who want a site and no involvement in the technical side."
/>
<ul className="!m-0 mt-8 max-w-2xl list-none !p-0">
{SPRINT_POINTS.map((point) => (
<li key={point} className="!mb-3 flex gap-3 text-base text-muted">
<span aria-hidden="true" className="font-mono text-accent">
+
</span>
<span>{point}</span>
</li>
))}
</ul>
<p className="!mb-0 mt-4 max-w-2xl font-mono text-sm text-faint">
Not part of it: email and DNS administration.
</p>
</Container>
</section>
);
-46
View File
@@ -1,46 +0,0 @@
import { Container, SectionHeader } from "@/src/components/site";
const OFFERS: readonly { title: string; text: string }[] = [
{
title: "Web and web apps",
text: "Next.js and TypeScript with strong design and frontend work, plus light backend for APIs, auth and integrations.",
},
{
title: "AI-code rescue",
text: "A 1-week entry block: audit of an AI-built codebase, test foundation, mutation baseline and a prioritized report.",
},
{
title: "CLI tools and automation",
text: "Command-line tools and automation, including AI agents and MCP servers.",
},
{
title: "Web3 frontends and systems",
text: "Wallet flows, dashboards and WebSockets, backed by a Rust and Solidity background.",
},
];
export const WhatIDo: React.FC = () => (
<section className="border-b border-border py-14 md:py-20">
<Container>
<SectionHeader kicker="what I do" title="What you can book" />
<ul className="!m-0 mt-10 grid list-none gap-4 !p-0 sm:grid-cols-2">
{OFFERS.map((offer) => (
<li
key={offer.title}
className="!mb-0 rounded-lg border border-border bg-surface p-5"
>
<h3 className="!mb-2 !mt-0 !text-lg !leading-snug md:!text-xl lg:!text-xl">
{offer.title}
</h3>
<p className="!mb-0 text-sm text-muted md:text-base">
{offer.text}
</p>
</li>
))}
</ul>
<p className="!mb-0 mt-6 font-mono text-sm text-faint">
Not offered: Windows apps, mobile apps, running your infrastructure.
</p>
</Container>
</section>
);