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,220 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
export type WorkItem = {
readonly name: string;
readonly href: string;
readonly client: string;
readonly text: string;
readonly did: readonly string[];
readonly tech: readonly string[];
readonly desktop: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly mobile: {
readonly src: string;
readonly width: number;
readonly height: number;
};
};
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
/** How far a tall screenshot has to move so its end reaches the bottom of the frame. */
const travel = (
shot: { width: number; height: number },
frameAspect: number,
): string => {
const visible = (shot.width / frameAspect / shot.height) * 100;
return `-${Math.max(0, 100 - visible).toFixed(2)}%`;
};
/** A screenshot that scrolls slowly through its frame while it is on screen. */
const ScrollingShot: React.FC<{
shot: { src: string; width: number; height: number };
frameAspect: number;
alt: string;
sizes: string;
active: boolean;
seconds: number;
}> = ({ shot, frameAspect, alt, sizes, active, seconds }) => (
<motion.div
animate={
active ? { y: ["0%", travel(shot, frameAspect), "0%"] } : { y: "0%" }
}
transition={
active
? {
duration: seconds,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.5, 1],
}
: { duration: 0.6 }
}
>
<Image
src={shot.src}
alt={alt}
width={shot.width}
height={shot.height}
sizes={sizes}
className="block h-auto w-full"
/>
</motion.div>
);
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
const [still, setStill] = React.useState(false);
React.useEffect(() => {
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0.3 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
const active = inView && !still;
return (
<div
ref={ref}
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 md:px-12 md:pt-14"
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
<div className="relative mx-auto max-w-4xl">
{/* Desktop */}
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden rounded-t-2xl border border-b-0 border-slate-700 bg-slate-950"
>
<div className="flex items-center gap-1.5 border-b border-slate-800 px-4 py-3">
<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 className="ml-3 rounded-full bg-slate-800 px-3 py-1 font-mono text-[10px] tracking-[0.1em] text-slate-400">
{item.name}
</span>
</div>
<div className="aspect-[16/10] overflow-hidden">
<ScrollingShot
shot={item.desktop}
frameAspect={16 / 10}
alt={`${item.name} on a desktop screen`}
sizes="(min-width: 1024px) 900px, 100vw"
active={active}
seconds={22}
/>
</div>
</motion.div>
{/* Phone */}
<motion.div
initial={{ opacity: 0, y: 60 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 0.9, delay: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="absolute -right-2 bottom-0 w-[26%] min-w-[92px] overflow-hidden rounded-t-[1.25rem] border-[3px] border-b-0 border-slate-700 bg-slate-950 shadow-2xl shadow-black/60 md:-right-8 md:rounded-t-[2rem] md:border-[5px]"
>
<div className="aspect-[9/17] overflow-hidden">
<ScrollingShot
shot={item.mobile}
frameAspect={9 / 17}
alt={`${item.name} on a phone`}
sizes="240px"
active={active}
seconds={26}
/>
</div>
</motion.div>
</div>
</div>
);
};
/**
* WorkShowcase: each live site on a dark stage, as a desktop and a phone view
* that scroll through the real pages.
*/
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
items,
}) => (
<div className="flex flex-col gap-20 md:gap-32">
{items.map((item, index) => (
<article
key={item.name}
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
<WorkStage item={item} />
</div>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}>
0{index + 1} · {item.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{item.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
{item.did.map((line) => (
<li
key={line}
className="border-t border-slate-200 py-3 font-sans text-base text-slate-900 md:text-lg"
>
{line}
</li>
))}
</ul>
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
{item.tech.map((tech) => (
<li
key={tech}
className="rounded-full border border-slate-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open the live site <span aria-hidden="true">↗</span>
</a>
</div>
</article>
))}
</div>
);