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,57 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
export type CareerStation = {
readonly years: string;
readonly role: string;
readonly place: string;
};
/**
* CareerLine: the path so far as a line that draws itself, one station after another.
*/
export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
stations,
}) => (
<ol className="relative m-0 list-none p-0">
<motion.span
className="absolute left-[5px] top-2 w-px origin-top bg-slate-900 md:left-[7px]"
style={{ bottom: 8 }}
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
{stations.map((station, index) => (
<motion.li
key={station.years}
className="relative grid grid-cols-1 gap-1 pb-10 pl-8 last:pb-0 md:grid-cols-12 md:gap-8 md:pl-12"
initial={{ opacity: 0, x: -8 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }}
>
<span
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-slate-900 md:h-[15px] md:w-[15px] ${
index === stations.length - 1 ? "bg-slate-900" : "bg-white"
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500 md:col-span-3 md:pt-1.5">
{station.years}
</span>
<span className="md:col-span-9">
<span className="block font-sans text-xl font-bold tracking-tight text-slate-900 md:text-3xl">
{station.role}
</span>
<span className="mt-1 block font-sans text-base text-slate-500 md:text-lg">
{station.place}
</span>
</span>
</motion.li>
))}
</ol>
);