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