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,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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user