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,449 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
import {
|
||||
KIND_LABEL,
|
||||
WORKFLOWS,
|
||||
type FlowNode,
|
||||
type NodeKind,
|
||||
type Workflow,
|
||||
} from "@/src/content/automation";
|
||||
|
||||
/**
|
||||
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
|
||||
* travelling through it. Each run plays step by step and writes a log beside it.
|
||||
*/
|
||||
|
||||
const WIDE = { w: 920, h: 400 } as const;
|
||||
const TALL = { w: 400, h: 540 } as const;
|
||||
const NODE_W = 172;
|
||||
const NODE_H = 64;
|
||||
const TRAVEL_MS = 1050;
|
||||
const DWELL_MS = 520;
|
||||
const PAUSE_MS = 1400;
|
||||
const MARK = "#fde68a";
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
/** A node placed for the current canvas: wide runs left to right, tall runs top to bottom. */
|
||||
type Placed = FlowNode & { readonly cx: number; readonly cy: number };
|
||||
|
||||
const place = (node: FlowNode, tall: boolean): Placed => ({
|
||||
...node,
|
||||
cx: tall ? node.nx : node.x,
|
||||
cy: tall ? node.ny : node.y,
|
||||
});
|
||||
|
||||
/** Cubic curve between two nodes, leaving and entering along the flow direction. */
|
||||
const curve = (from: Placed, to: Placed, tall: boolean) => {
|
||||
if (tall) {
|
||||
const p0: Point = { x: from.cx, y: from.cy + NODE_H / 2 };
|
||||
const p3: Point = { x: to.cx, y: to.cy - NODE_H / 2 };
|
||||
const bend = Math.max(24, (p3.y - p0.y) / 2);
|
||||
return {
|
||||
p0,
|
||||
p1: { x: p0.x, y: p0.y + bend },
|
||||
p2: { x: p3.x, y: p3.y - bend },
|
||||
p3,
|
||||
};
|
||||
}
|
||||
const p0: Point = { x: from.cx + NODE_W / 2, y: from.cy };
|
||||
const p3: Point = { x: to.cx - NODE_W / 2, y: to.cy };
|
||||
const bend = Math.max(40, (p3.x - p0.x) / 2);
|
||||
return {
|
||||
p0,
|
||||
p1: { x: p0.x + bend, y: p0.y },
|
||||
p2: { x: p3.x - bend, y: p3.y },
|
||||
p3,
|
||||
};
|
||||
};
|
||||
|
||||
const pathOf = (from: Placed, to: Placed, tall: boolean): string => {
|
||||
const { p0, p1, p2, p3 } = curve(from, to, tall);
|
||||
return `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`;
|
||||
};
|
||||
|
||||
const pointOn = (from: Placed, to: Placed, t: number, tall: boolean): Point => {
|
||||
const { p0, p1, p2, p3 } = curve(from, to, tall);
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x:
|
||||
u * u * u * p0.x +
|
||||
3 * u * u * t * p1.x +
|
||||
3 * u * t * t * p2.x +
|
||||
t * t * t * p3.x,
|
||||
y:
|
||||
u * u * u * p0.y +
|
||||
3 * u * u * t * p1.y +
|
||||
3 * u * t * t * p2.y +
|
||||
t * t * t * p3.y,
|
||||
};
|
||||
};
|
||||
|
||||
const ease = (t: number): number =>
|
||||
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
|
||||
const KIND_STYLE: Readonly<
|
||||
Record<NodeKind, { stroke: string; dash?: string; tag: string }>
|
||||
> = {
|
||||
trigger: { stroke: "#e2e8f0", tag: "#e2e8f0" },
|
||||
rule: { stroke: "#64748b", tag: "#94a3b8" },
|
||||
ai: { stroke: "#fde68a", dash: "5 4", tag: "#fde68a" },
|
||||
action: { stroke: "#64748b", tag: "#94a3b8" },
|
||||
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
|
||||
};
|
||||
|
||||
type Clock = {
|
||||
/** Which run of the workflow is playing. */
|
||||
readonly run: number;
|
||||
/** Step within the run; equal to steps.length while pausing at the end. */
|
||||
readonly step: number;
|
||||
/** 0..1 progress of the travelling packets within the step. */
|
||||
readonly t: number;
|
||||
};
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const AutomationFlow: React.FC = () => {
|
||||
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
|
||||
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
|
||||
const tall = useNarrow();
|
||||
const canvas = tall ? TALL : WIDE;
|
||||
|
||||
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!flow) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setStill(true);
|
||||
return;
|
||||
}
|
||||
if (!inView) return;
|
||||
|
||||
let raf = 0;
|
||||
let run = 0;
|
||||
let step = 0;
|
||||
let stepStart = performance.now();
|
||||
setClock({ run: 0, step: 0, t: 0 });
|
||||
|
||||
const tick = (now: number) => {
|
||||
const steps = flow.runs[run % flow.runs.length] ?? [];
|
||||
const elapsed = now - stepStart;
|
||||
if (step >= steps.length) {
|
||||
if (elapsed > PAUSE_MS) {
|
||||
run += 1;
|
||||
step = 0;
|
||||
stepStart = now;
|
||||
}
|
||||
} else if (elapsed > TRAVEL_MS + DWELL_MS) {
|
||||
step += 1;
|
||||
stepStart = now;
|
||||
}
|
||||
const t =
|
||||
step >= steps.length ? 1 : Math.min(1, (now - stepStart) / TRAVEL_MS);
|
||||
setClock({ run, step, t });
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [flow, inView]);
|
||||
|
||||
if (!flow) return null;
|
||||
|
||||
const placed = flow.nodes.map((node) => place(node, tall));
|
||||
const nodeOf = (id: string): Placed | undefined =>
|
||||
placed.find((node) => node.id === id);
|
||||
|
||||
const steps = flow.runs[clock.run % flow.runs.length] ?? [];
|
||||
const firstNode = steps[0]?.[0]?.[0];
|
||||
|
||||
// Nodes a packet has already arrived at in this run.
|
||||
const reached = new Set<string>();
|
||||
if (still) {
|
||||
flow.nodes.forEach((node) => reached.add(node.id));
|
||||
} else {
|
||||
if (firstNode) reached.add(firstNode);
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
index < clock.step || (index === clock.step && clock.t >= 1);
|
||||
if (arrived) edges.forEach(([, to]) => reached.add(to));
|
||||
});
|
||||
}
|
||||
|
||||
const activeEdges = still ? [] : (steps[clock.step] ?? []);
|
||||
const travelled = new Set<string>();
|
||||
steps.forEach((edges, index) => {
|
||||
if (index < clock.step || (index === clock.step && clock.t >= 1)) {
|
||||
edges.forEach(([from, to]) => travelled.add(`${from}>${to}`));
|
||||
}
|
||||
});
|
||||
|
||||
const logLines: string[] = [];
|
||||
if (firstNode) {
|
||||
const first = nodeOf(firstNode);
|
||||
if (first) logLines.push(first.log);
|
||||
}
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
index < clock.step || (index === clock.step && clock.t >= 1);
|
||||
if (!arrived) return;
|
||||
edges.forEach(([, to]) => {
|
||||
const node = nodeOf(to);
|
||||
if (node && !logLines.includes(node.log)) logLines.push(node.log);
|
||||
});
|
||||
});
|
||||
const finished = clock.step >= steps.length;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Example workflows"
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{WORKFLOWS.map((item) => {
|
||||
const selected = item.id === flow.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => setFlowId(item.id)}
|
||||
className={`rounded-full border px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="m-0 mt-6 max-w-3xl font-sans text-lg leading-relaxed text-slate-600 md:text-xl">
|
||||
{flow.summary}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-3xl bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
|
||||
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
|
||||
/>
|
||||
{finished ? "run complete" : "running"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
role="img"
|
||||
aria-label={`${flow.name}: ${flow.summary}`}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="flow-grid"
|
||||
width="20"
|
||||
height="20"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" fill="#1e293b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
|
||||
|
||||
{flow.edges.map(([from, to]) => {
|
||||
const a = nodeOf(from);
|
||||
const b = nodeOf(to);
|
||||
if (!a || !b) return null;
|
||||
const done = still || travelled.has(`${from}>${to}`);
|
||||
return (
|
||||
<path
|
||||
key={`${from}>${to}`}
|
||||
d={pathOf(a, b, tall)}
|
||||
fill="none"
|
||||
stroke={done ? "#e2e8f0" : "#334155"}
|
||||
strokeWidth={done ? 2 : 1.5}
|
||||
style={{ transition: "stroke 300ms" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeEdges.map(([from, to]) => {
|
||||
const a = nodeOf(from);
|
||||
const b = nodeOf(to);
|
||||
if (!a || !b || clock.t >= 1) return null;
|
||||
const eased = ease(clock.t);
|
||||
return (
|
||||
<g key={`packet-${from}>${to}`}>
|
||||
{[0.16, 0.1, 0.05].map((lag, i) => {
|
||||
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
|
||||
return (
|
||||
<circle
|
||||
key={lag}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={5 - i}
|
||||
fill={MARK}
|
||||
opacity={0.12 + i * 0.12}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle
|
||||
cx={pointOn(a, b, eased, tall).x}
|
||||
cy={pointOn(a, b, eased, tall).y}
|
||||
r={7}
|
||||
fill={MARK}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{placed.map((node) => {
|
||||
const style = KIND_STYLE[node.kind];
|
||||
const on = reached.has(node.id);
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<rect
|
||||
x={node.cx - NODE_W / 2}
|
||||
y={node.cy - NODE_H / 2}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={14}
|
||||
fill={on ? "#1e293b" : "#0f172a"}
|
||||
stroke={on ? style.stroke : "#334155"}
|
||||
strokeWidth={on ? 1.75 : 1.25}
|
||||
strokeDasharray={style.dash}
|
||||
style={{ transition: "fill 300ms, stroke 300ms" }}
|
||||
/>
|
||||
<text
|
||||
x={node.cx - NODE_W / 2 + 16}
|
||||
y={node.cy - 8}
|
||||
fill={on ? style.tag : "#64748b"}
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
transition: "fill 300ms",
|
||||
}}
|
||||
>
|
||||
{node.system}
|
||||
</text>
|
||||
<text
|
||||
x={node.cx - NODE_W / 2 + 16}
|
||||
y={node.cy + 14}
|
||||
fill={on ? "#f8fafc" : "#94a3b8"}
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-inter), Inter, system-ui, sans-serif",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
transition: "fill 300ms",
|
||||
}}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
{on && (
|
||||
<circle
|
||||
cx={node.cx + NODE_W / 2 - 16}
|
||||
cy={node.cy - NODE_H / 2 + 16}
|
||||
r={4}
|
||||
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
|
||||
<ol
|
||||
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
|
||||
aria-live="off"
|
||||
>
|
||||
{logLines.map((line, index) => (
|
||||
<li key={line} className="flex gap-3">
|
||||
<span className="text-slate-600">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
line.startsWith("unclear") ? "text-amber-200" : undefined
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
|
||||
{(Object.keys(KIND_LABEL) as NodeKind[])
|
||||
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
|
||||
.map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{KIND_LABEL[kind]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{flow.byHand}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
|
||||
{flow.automated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
|
||||
const INK = "#f8fafc";
|
||||
const LINE = "#475569";
|
||||
const MARK = "#fde68a";
|
||||
const NODE_W = 112;
|
||||
const NODE_H = 38;
|
||||
|
||||
const CHAIN = ["You", "Sales", "Manager", "Designer", "Developer"] as const;
|
||||
|
||||
const Node: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
size: number;
|
||||
solid?: boolean;
|
||||
muted?: boolean;
|
||||
}> = ({ x, y, label, size, solid = false, muted = false }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={x - NODE_W / 2}
|
||||
y={y - NODE_H / 2}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={NODE_H / 2}
|
||||
fill={solid ? INK : "#0f172a"}
|
||||
stroke={muted ? LINE : INK}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={muted ? "4 4" : undefined}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={y + size * 0.36}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
fontSize: size,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fill: solid ? "#0f172a" : muted ? "#94a3b8" : INK,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const Caption: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
strong?: boolean;
|
||||
children: string;
|
||||
}> = ({ x, y, size, strong = false, children }) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
fontSize: size,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fill: strong ? INK : "#64748b",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
|
||||
/**
|
||||
* ContactDiagram: a request passing slowly from hand to hand, next to the direct
|
||||
* line you get when you talk to the person who does the work. Wide screens show
|
||||
* the two lanes as rows, phones as two columns.
|
||||
*/
|
||||
export const ContactDiagram: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const label =
|
||||
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.";
|
||||
|
||||
if (tall) {
|
||||
// Two columns: the chain on the left, the direct line on the right.
|
||||
const ys = CHAIN.map((_, i) => 96 + i * 84);
|
||||
const first = ys[0] ?? 96;
|
||||
const last = ys[ys.length - 1] ?? 432;
|
||||
const stops = ys.flatMap((y) => [y, y]);
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 340 500"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<Caption x={24} y={38} size={12}>
|
||||
often
|
||||
</Caption>
|
||||
<Caption x={196} y={38} size={12} strong>
|
||||
with me
|
||||
</Caption>
|
||||
<line
|
||||
x1={90}
|
||||
x2={90}
|
||||
y1={first}
|
||||
y2={last}
|
||||
stroke={LINE}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={90}
|
||||
y={ys[i] ?? 0}
|
||||
label={name}
|
||||
size={12}
|
||||
muted={i > 0}
|
||||
/>
|
||||
))}
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={160}
|
||||
fill={MARK}
|
||||
initial={{ cy: first }}
|
||||
animate={{
|
||||
cy: stops,
|
||||
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
|
||||
}}
|
||||
transition={{
|
||||
duration: 9,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<line
|
||||
x1={250}
|
||||
x2={250}
|
||||
y1={first}
|
||||
y2={last}
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={250} y={first} label="You" size={12} />
|
||||
<Node x={250} y={last} label="Me" size={12} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={250}
|
||||
fill={MARK}
|
||||
initial={{ cy: first + NODE_H / 2 }}
|
||||
animate={{
|
||||
cy: [first + NODE_H / 2, last - NODE_H / 2, first + NODE_H / 2],
|
||||
}}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const xs = CHAIN.map((_, i) => 76 + i * 132);
|
||||
const first = xs[0] ?? 76;
|
||||
const last = xs[xs.length - 1] ?? 604;
|
||||
const stops = xs.flatMap((x) => [x, x]);
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 250"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="contact-grid"
|
||||
width="20"
|
||||
height="20"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" fill="#1e293b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="680" height="250" fill="url(#contact-grid)" />
|
||||
|
||||
<Caption x={20} y={30} size={10}>
|
||||
often: a request is passed along
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
x2={last}
|
||||
y1={74}
|
||||
y2={74}
|
||||
stroke={LINE}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={xs[i] ?? 0}
|
||||
y={74}
|
||||
label={name}
|
||||
size={10}
|
||||
muted={i > 0}
|
||||
/>
|
||||
))}
|
||||
<motion.circle
|
||||
r={5}
|
||||
cy={46}
|
||||
fill={MARK}
|
||||
initial={{ cx: first }}
|
||||
animate={{
|
||||
cx: stops,
|
||||
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
|
||||
}}
|
||||
transition={{
|
||||
duration: 9,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Caption x={20} y={160} size={10} strong>
|
||||
with me: one direct line
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
x2={last}
|
||||
y1={204}
|
||||
y2={204}
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={first} y={204} label="You" size={10} />
|
||||
<Node x={last} y={204} label="Me" size={10} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cy={204}
|
||||
fill={MARK}
|
||||
initial={{ cx: first + NODE_W / 2 }}
|
||||
animate={{
|
||||
cx: [first + NODE_W / 2, last - NODE_W / 2, first + NODE_W / 2],
|
||||
}}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,964 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* PathScenes: one small animated drawing per step of a service path.
|
||||
* They explain what happens at that step; they are not decoration.
|
||||
* All scenes share a 480 x 300 canvas and the same line language.
|
||||
*/
|
||||
|
||||
const INK = "#f8fafc";
|
||||
const PAPER = "#0f172a";
|
||||
const LINE = "#475569";
|
||||
const SOFT = "#1e293b";
|
||||
const MARK = "#fde68a";
|
||||
const LIVE = "#10b981";
|
||||
const MONO = {
|
||||
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
// Set on the svg: larger on phones, where the drawing is scaled down.
|
||||
fontSize: "var(--scene-label, 10px)",
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase" as const,
|
||||
fill: "#94a3b8",
|
||||
};
|
||||
|
||||
const draw = (delay: number) => ({
|
||||
initial: { pathLength: 0, opacity: 0 },
|
||||
animate: { pathLength: 1, opacity: 1 },
|
||||
transition: { duration: 0.6, delay, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
const appear = (delay: number, y = 6) => ({
|
||||
initial: { opacity: 0, y },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.4, delay, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
type Pt = readonly [number, number];
|
||||
|
||||
/** Points along a cubic curve, for packets that travel on it. */
|
||||
const along = (p0: Pt, p1: Pt, p2: Pt, p3: Pt, samples = 12) => {
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples;
|
||||
const u = 1 - t;
|
||||
xs.push(
|
||||
u * u * u * p0[0] +
|
||||
3 * u * u * t * p1[0] +
|
||||
3 * u * t * t * p2[0] +
|
||||
t * t * t * p3[0],
|
||||
);
|
||||
ys.push(
|
||||
u * u * u * p0[1] +
|
||||
3 * u * u * t * p1[1] +
|
||||
3 * u * t * t * p2[1] +
|
||||
t * t * t * p3[1],
|
||||
);
|
||||
}
|
||||
return { xs, ys };
|
||||
};
|
||||
|
||||
/** A small packet that keeps travelling a route. */
|
||||
const Packet: React.FC<{
|
||||
xs: readonly number[];
|
||||
ys: readonly number[];
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
pause?: number;
|
||||
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
|
||||
<motion.circle
|
||||
r={5}
|
||||
fill={MARK}
|
||||
initial={{ cx: xs[0], cy: ys[0], opacity: 0 }}
|
||||
animate={{
|
||||
cx: [...xs],
|
||||
cy: [...ys],
|
||||
opacity: xs.map((_, i) => (i === 0 || i === xs.length - 1 ? 0 : 1)),
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
delay,
|
||||
repeat: Infinity,
|
||||
repeatDelay: pause,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
|
||||
x,
|
||||
label,
|
||||
filled = false,
|
||||
}) => (
|
||||
<g>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={128}
|
||||
r={20}
|
||||
fill={filled ? INK : PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<path
|
||||
d={`M ${x - 34} 196 Q ${x - 34} 158 ${x} 158 Q ${x + 34} 158 ${x + 34} 196`}
|
||||
fill={filled ? INK : PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text x={x} y={222} textAnchor="middle" style={MONO}>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Step 1 of every path: a short conversation. */
|
||||
const Talk: React.FC = () => (
|
||||
<g>
|
||||
<Person x={84} label="You" />
|
||||
<Person x={396} label="Me" filled />
|
||||
{[
|
||||
{ x: 150, y: 86, w: 120, side: "left", delay: 0.2 },
|
||||
{ x: 210, y: 124, w: 120, side: "right", delay: 0.7 },
|
||||
{ x: 150, y: 162, w: 96, side: "left", delay: 1.2 },
|
||||
].map((b) => (
|
||||
<motion.g key={b.y} {...appear(b.delay)}>
|
||||
<rect
|
||||
x={b.x}
|
||||
y={b.y}
|
||||
width={b.w}
|
||||
height={26}
|
||||
rx={13}
|
||||
fill={b.side === "left" ? SOFT : INK}
|
||||
stroke={b.side === "left" ? LINE : INK}
|
||||
/>
|
||||
<line
|
||||
x1={b.x + 14}
|
||||
x2={b.x + b.w - 14}
|
||||
y1={b.y + 13}
|
||||
y2={b.y + 13}
|
||||
stroke={b.side === "left" ? "#94a3b8" : PAPER}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
<motion.g {...appear(1.7)}>
|
||||
<rect x={258} y={200} width={72} height={26} rx={13} fill={INK} />
|
||||
{[0, 1, 2].map((dot) => (
|
||||
<motion.circle
|
||||
key={dot}
|
||||
cx={280 + dot * 14}
|
||||
cy={213}
|
||||
r={3}
|
||||
fill={PAPER}
|
||||
animate={{ opacity: [0.2, 1, 0.2] }}
|
||||
transition={{ duration: 1.1, delay: dot * 0.18, repeat: Infinity }}
|
||||
/>
|
||||
))}
|
||||
</motion.g>
|
||||
<motion.text
|
||||
x={240}
|
||||
y={262}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.6, 0)}
|
||||
>
|
||||
about 20 minutes
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
|
||||
|
||||
/** Developer, step 2: agreed days of the week. */
|
||||
const WeeklyHours: React.FC = () => (
|
||||
<g>
|
||||
{DAYS.map((day, d) => (
|
||||
<g key={day}>
|
||||
<text x={78 + d * 72} y={52} textAnchor="middle" style={MONO}>
|
||||
{day}
|
||||
</text>
|
||||
{Array.from({ length: 8 }, (_, h) => {
|
||||
const reserved = d < 3;
|
||||
return (
|
||||
<motion.rect
|
||||
key={h}
|
||||
x={52 + d * 72}
|
||||
y={64 + h * 22}
|
||||
width={52}
|
||||
height={16}
|
||||
rx={4}
|
||||
initial={{ fill: PAPER, stroke: LINE }}
|
||||
animate={{
|
||||
fill: reserved ? INK : PAPER,
|
||||
stroke: reserved ? INK : LINE,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.25,
|
||||
delay: reserved ? 0.2 + (d * 8 + h) * 0.035 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.3, 0)}
|
||||
>
|
||||
for example three days a week
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 3: the list gets worked through and comes back for review. */
|
||||
const WorkList: React.FC = () => (
|
||||
<g>
|
||||
{[0, 1, 2, 3].map((row) => {
|
||||
const done = row < 3;
|
||||
const y = 60 + row * 46;
|
||||
return (
|
||||
<g key={row}>
|
||||
<rect
|
||||
x={60}
|
||||
y={y}
|
||||
width={250}
|
||||
height={32}
|
||||
rx={8}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
/>
|
||||
<rect
|
||||
x={72}
|
||||
y={y + 8}
|
||||
width={16}
|
||||
height={16}
|
||||
rx={4}
|
||||
fill={done ? INK : PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{done && (
|
||||
<motion.path
|
||||
d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`}
|
||||
fill="none"
|
||||
stroke={PAPER}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...draw(0.3 + row * 0.45)}
|
||||
/>
|
||||
)}
|
||||
<line
|
||||
x1={102}
|
||||
x2={102 + [150, 110, 170, 130][row]!}
|
||||
y1={y + 16}
|
||||
y2={y + 16}
|
||||
stroke={done ? INK : LINE}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{done && (
|
||||
<motion.g {...appear(0.6 + row * 0.45, 0)}>
|
||||
<path
|
||||
d={`M 322 ${y + 16} h 46`}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#arrow)"
|
||||
/>
|
||||
<Packet
|
||||
xs={[322, 334, 346, 358, 366]}
|
||||
ys={[y + 16, y + 16, y + 16, y + 16, y + 16]}
|
||||
delay={1.2 + row * 0.5}
|
||||
duration={0.9}
|
||||
pause={1.8}
|
||||
/>
|
||||
<rect
|
||||
x={378}
|
||||
y={y + 4}
|
||||
width={48}
|
||||
height={24}
|
||||
rx={12}
|
||||
fill={MARK}
|
||||
/>
|
||||
<text
|
||||
x={402}
|
||||
y={y + 20}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
...MONO,
|
||||
fill: PAPER,
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
finished parts go to you for review
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 4: two weeks notice to the end of a month. */
|
||||
const Notice: React.FC = () => (
|
||||
<g>
|
||||
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={2} />
|
||||
{Array.from({ length: 5 }, (_, w) => (
|
||||
<g key={w}>
|
||||
<line
|
||||
x1={50 + w * 95}
|
||||
x2={50 + w * 95}
|
||||
y1={140}
|
||||
y2={160}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
|
||||
{w === 4 ? "end of month" : `week ${w + 1}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<motion.rect
|
||||
x={240}
|
||||
y={132}
|
||||
height={36}
|
||||
rx={6}
|
||||
fill={MARK}
|
||||
initial={{ width: 0, opacity: 0.35 }}
|
||||
animate={{ width: 190 }}
|
||||
transition={{ duration: 0.8, delay: 0.4, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.g {...appear(1.1)}>
|
||||
<path
|
||||
d="M 240 118 v -14 h 190 v 14"
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
||||
two weeks notice
|
||||
</text>
|
||||
</motion.g>
|
||||
<motion.circle cx={430} cy={150} r={7} fill={INK} {...appear(1.4, 0)} />
|
||||
</g>
|
||||
);
|
||||
|
||||
const NODES: readonly (readonly [number, number])[] = [
|
||||
[90, 80],
|
||||
[200, 56],
|
||||
[320, 76],
|
||||
[410, 120],
|
||||
[130, 170],
|
||||
[250, 140],
|
||||
[350, 190],
|
||||
[220, 220],
|
||||
];
|
||||
const EDGES: readonly (readonly [number, number])[] = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[0, 4],
|
||||
[1, 5],
|
||||
[4, 5],
|
||||
[5, 2],
|
||||
[5, 6],
|
||||
[3, 6],
|
||||
[4, 7],
|
||||
[7, 6],
|
||||
];
|
||||
|
||||
/** Rescue, step 2: fragile connections become solid over the week. */
|
||||
const RepairWeek: React.FC = () => (
|
||||
<g>
|
||||
{EDGES.map(([a, b], i) => {
|
||||
const from = NODES[a]!;
|
||||
const to = NODES[b]!;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={from[0]}
|
||||
y1={from[1]}
|
||||
x2={to[0]}
|
||||
y2={to[1]}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 5"
|
||||
/>
|
||||
<motion.line
|
||||
x1={from[0]}
|
||||
y1={from[1]}
|
||||
x2={to[0]}
|
||||
y2={to[1]}
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
{...draw(0.4 + i * 0.22)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{NODES.map(([x, y], i) => (
|
||||
<motion.circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={7}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
initial={{ fill: PAPER }}
|
||||
animate={{ fill: LIVE }}
|
||||
transition={{ duration: 0.4, delay: 1 + i * 0.28 }}
|
||||
/>
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(2.6, 0)}
|
||||
>
|
||||
fragile parts get tests and fixes
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 3: a written summary. */
|
||||
const Summary: React.FC = () => (
|
||||
<g>
|
||||
<rect
|
||||
x={150}
|
||||
y={36}
|
||||
width={180}
|
||||
height={214}
|
||||
rx={10}
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{["found", "changed", "next"].map((label, s) => (
|
||||
<g key={label}>
|
||||
<motion.text
|
||||
x={170}
|
||||
y={72 + s * 62}
|
||||
style={{ ...MONO, fill: INK }}
|
||||
{...appear(0.2 + s * 0.6, 0)}
|
||||
>
|
||||
{label}
|
||||
</motion.text>
|
||||
{[0, 1, 2].map((l) => (
|
||||
<motion.line
|
||||
key={l}
|
||||
x1={170}
|
||||
x2={170 + [140, 118, 90][l]!}
|
||||
y1={84 + s * 62 + l * 11}
|
||||
y2={84 + s * 62 + l * 11}
|
||||
stroke={LINE}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
{...draw(0.35 + s * 0.6 + l * 0.12)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={278}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(2.2, 0)}
|
||||
>
|
||||
written so you can follow it
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 4: the client decides how to continue. */
|
||||
const Decide: React.FC = () => (
|
||||
<g>
|
||||
<circle cx={90} cy={150} r={26} fill={INK} />
|
||||
<text
|
||||
x={90}
|
||||
y={154}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
...MONO,
|
||||
fill: PAPER,
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
</text>
|
||||
{[
|
||||
{ y: 70, label: "continue with me" },
|
||||
{ y: 150, label: "your own team" },
|
||||
{ y: 230, label: "stop here" },
|
||||
].map((option, i) => (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
|
||||
const Browser: React.FC<{
|
||||
children: React.ReactNode;
|
||||
url?: string;
|
||||
live?: boolean;
|
||||
}> = ({ children, url, live = false }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={70}
|
||||
y={30}
|
||||
width={340}
|
||||
height={224}
|
||||
rx={12}
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={1.5} />
|
||||
{[0, 1, 2].map((d) => (
|
||||
<circle key={d} cx={88 + d * 12} cy={44} r={3} fill={LINE} />
|
||||
))}
|
||||
<rect x={134} y={37} width={200} height={14} rx={7} fill={SOFT} />
|
||||
{url && (
|
||||
<text
|
||||
x={146}
|
||||
y={47.5}
|
||||
style={{
|
||||
...MONO,
|
||||
fontSize: "calc(var(--scene-label, 10px) - 2px)",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</text>
|
||||
)}
|
||||
{live && (
|
||||
<motion.g {...appear(1.6, 0)}>
|
||||
<motion.circle
|
||||
cx={352}
|
||||
cy={44}
|
||||
r={4}
|
||||
fill={LIVE}
|
||||
animate={{ opacity: [1, 0.35, 1] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity }}
|
||||
/>
|
||||
<text
|
||||
x={362}
|
||||
y={47.5}
|
||||
style={{
|
||||
...MONO,
|
||||
fontSize: "calc(var(--scene-label, 10px) - 2px)",
|
||||
fill: LIVE,
|
||||
}}
|
||||
>
|
||||
online
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
|
||||
const BLOCKS: readonly { x: number; y: number; w: number; h: number }[] = [
|
||||
{ x: 88, y: 72, w: 304, h: 14 },
|
||||
{ x: 88, y: 98, w: 190, h: 64 },
|
||||
{ x: 290, y: 98, w: 102, h: 64 },
|
||||
{ x: 88, y: 174, w: 94, h: 62 },
|
||||
{ x: 193, y: 174, w: 94, h: 62 },
|
||||
{ x: 298, y: 174, w: 94, h: 62 },
|
||||
];
|
||||
|
||||
/** Website, step 2: a first design as outlines. */
|
||||
const Wireframe: React.FC = () => (
|
||||
<g>
|
||||
<Browser>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
x={b.x}
|
||||
y={b.y}
|
||||
width={b.w}
|
||||
height={b.h}
|
||||
rx={5}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="4 4"
|
||||
{...appear(0.2 + i * 0.18, 0)}
|
||||
/>
|
||||
))}
|
||||
</Browser>
|
||||
<motion.text
|
||||
x={240}
|
||||
y={282}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
a first version to click through
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 3: built and online. */
|
||||
const Built: React.FC = () => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
x={b.x}
|
||||
y={b.y}
|
||||
width={b.w}
|
||||
height={b.h}
|
||||
rx={5}
|
||||
initial={{ fill: PAPER, stroke: LINE }}
|
||||
animate={{ fill: i === 1 ? INK : SOFT, stroke: i === 1 ? INK : LINE }}
|
||||
transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
|
||||
/>
|
||||
))}
|
||||
</Browser>
|
||||
<motion.text
|
||||
x={240}
|
||||
y={282}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
you approve it before it goes live
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 4: a change request by email. */
|
||||
const ChangeByEmail: React.FC = () => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={b.x}
|
||||
y={b.y}
|
||||
width={b.w}
|
||||
height={b.h}
|
||||
rx={5}
|
||||
fill={i === 1 ? INK : SOFT}
|
||||
stroke={i === 1 ? INK : LINE}
|
||||
/>
|
||||
))}
|
||||
<motion.rect
|
||||
x={BLOCKS[4]!.x}
|
||||
y={BLOCKS[4]!.y}
|
||||
width={BLOCKS[4]!.w}
|
||||
height={BLOCKS[4]!.h}
|
||||
rx={5}
|
||||
fill={MARK}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 1.3 }}
|
||||
/>
|
||||
</Browser>
|
||||
<motion.g {...appear(0.2)}>
|
||||
<rect
|
||||
x={16}
|
||||
y={186}
|
||||
width={46}
|
||||
height={32}
|
||||
rx={5}
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<path
|
||||
d="M 16 190 l 23 16 l 23 -16"
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</motion.g>
|
||||
<motion.path
|
||||
d="M 66 202 C 120 202 150 205 188 205"
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#arrow)"
|
||||
{...draw(0.6)}
|
||||
/>
|
||||
<motion.text
|
||||
x={240}
|
||||
y={282}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.7, 0)}
|
||||
>
|
||||
you send an email, I make the change
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const TOOLS: readonly { x: number; y: number; label: string }[] = [
|
||||
{ x: 90, y: 80, label: "Shop" },
|
||||
{ x: 390, y: 80, label: "Accounting" },
|
||||
{ x: 90, y: 200, label: "Email" },
|
||||
{ x: 390, y: 200, label: "CRM" },
|
||||
];
|
||||
|
||||
const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
x,
|
||||
y,
|
||||
label,
|
||||
}) => (
|
||||
<g>
|
||||
<rect
|
||||
x={x - 56}
|
||||
y={y - 22}
|
||||
width={112}
|
||||
height={44}
|
||||
rx={10}
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Automation, step 2: today a person carries the data from tool to tool. */
|
||||
const ByHand: React.FC = () => (
|
||||
<g>
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
))}
|
||||
{TOOLS.map((tool, i) => (
|
||||
<motion.path
|
||||
key={tool.label}
|
||||
d={`M ${tool.x + (tool.x < 240 ? 56 : -56)} ${tool.y} L ${tool.x < 240 ? 214 : 266} ${140 + (tool.y < 140 ? -12 : 12)}`}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 5"
|
||||
{...draw(0.3 + i * 0.25)}
|
||||
/>
|
||||
))}
|
||||
<motion.g {...appear(0.2, 0)}>
|
||||
<circle
|
||||
cx={240}
|
||||
cy={128}
|
||||
r={13}
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<path
|
||||
d="M 218 172 Q 218 146 240 146 Q 262 146 262 172"
|
||||
fill={PAPER}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</motion.g>
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
today: copied by hand
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const WIRES: readonly string[] = [
|
||||
"M 146 80 H 334",
|
||||
"M 90 102 V 178",
|
||||
"M 390 102 V 178",
|
||||
"M 146 200 H 334",
|
||||
];
|
||||
|
||||
/** Automation, step 3: the systems are connected one step at a time. */
|
||||
const Wiring: React.FC = () => (
|
||||
<g>
|
||||
{WIRES.map((d, i) => (
|
||||
<motion.path
|
||||
key={d}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(2, 0)}
|
||||
>
|
||||
connected one step at a time
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Automation, step 4: the work travels between the systems on its own. */
|
||||
const Running: React.FC = () => (
|
||||
<g>
|
||||
{WIRES.map((d) => (
|
||||
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={2} />
|
||||
))}
|
||||
{[
|
||||
{ from: [146, 80], to: [334, 80], delay: 0 },
|
||||
{ from: [390, 102], to: [390, 178], delay: 0.7 },
|
||||
{ from: [334, 200], to: [146, 200], delay: 1.4 },
|
||||
{ from: [90, 178], to: [90, 102], delay: 2.1 },
|
||||
].map((dot) => (
|
||||
<motion.circle
|
||||
key={dot.delay}
|
||||
r={6}
|
||||
fill={MARK}
|
||||
stroke={INK}
|
||||
strokeWidth={1.5}
|
||||
initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }}
|
||||
animate={{
|
||||
cx: [dot.from[0]!, dot.to[0]!],
|
||||
cy: [dot.from[1]!, dot.to[1]!],
|
||||
opacity: [0, 1, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
delay: dot.delay,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 1.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
textAnchor="middle"
|
||||
style={MONO}
|
||||
{...appear(0.6, 0)}
|
||||
>
|
||||
the steps run without anyone copying
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const SCENES: Readonly<Record<string, readonly React.FC[]>> = {
|
||||
developer: [Talk, WeeklyHours, WorkList, Notice],
|
||||
rescue: [Talk, RepairWeek, Summary, Decide],
|
||||
automation: [Talk, ByHand, Wiring, Running],
|
||||
website: [Talk, Wireframe, Built, ChangeByEmail],
|
||||
};
|
||||
|
||||
export const PathScene: React.FC<{
|
||||
pathId: string;
|
||||
step: number;
|
||||
title: string;
|
||||
}> = ({ pathId, step, title }) => {
|
||||
const Scene = SCENES[pathId]?.[step];
|
||||
if (!Scene) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 480 300"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className="block h-auto w-full [--scene-label:14px] md:[--scene-label:10px]"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
|
||||
</marker>
|
||||
</defs>
|
||||
<defs>
|
||||
<pattern
|
||||
id="scene-grid"
|
||||
width="20"
|
||||
height="20"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" fill="#1e293b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="480" height="300" fill="url(#scene-grid)" />
|
||||
{/* Remount per step so each drawing plays from the start. */}
|
||||
<motion.g
|
||||
key={`${pathId}-${step}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Scene />
|
||||
</motion.g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* PipelineRun: what happens between a change and its release, shown as the test run it is.
|
||||
* A change breaks something elsewhere, the run catches it, the release is blocked, the fix
|
||||
* goes in, the release goes out. Output is illustrative, modelled on a real test runner.
|
||||
*/
|
||||
|
||||
type Suite = {
|
||||
readonly file: string;
|
||||
readonly tests: number;
|
||||
readonly ms: number;
|
||||
};
|
||||
|
||||
const SUITES: readonly Suite[] = [
|
||||
{ file: "auth/login.spec.ts", tests: 9, ms: 412 },
|
||||
{ file: "orders/create.spec.ts", tests: 12, ms: 688 },
|
||||
{ file: "orders/status.spec.ts", tests: 7, ms: 301 },
|
||||
{ file: "invoices/total.spec.ts", tests: 14, ms: 954 },
|
||||
{ file: "emails/confirmation.spec.ts", tests: 6, ms: 377 },
|
||||
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
|
||||
];
|
||||
|
||||
type Scenario = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly branch: string;
|
||||
readonly failingFile: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: readonly Scenario[] = [
|
||||
{
|
||||
id: "orders",
|
||||
label: "Orders",
|
||||
branch: "change/order-discounts",
|
||||
failingFile: "invoices/total.spec.ts",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Login",
|
||||
branch: "change/login-with-username",
|
||||
failingFile: "emails/confirmation.spec.ts",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
label: "Invoices",
|
||||
branch: "change/invoice-layout",
|
||||
failingFile: "emails/invoice-mail.spec.ts",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
{
|
||||
id: "emails",
|
||||
label: "Emails",
|
||||
branch: "change/email-queue",
|
||||
failingFile: "orders/status.spec.ts",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
];
|
||||
|
||||
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
|
||||
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
|
||||
|
||||
/**
|
||||
* The run as a sequence of frames. Each frame says how far the first attempt got,
|
||||
* and whether the fix has been pushed and how far the second attempt got.
|
||||
*/
|
||||
type Frame = {
|
||||
readonly attempt: 1 | 2;
|
||||
/** Stages finished so far (0..4). */
|
||||
readonly stagesDone: number;
|
||||
/** Suites reported so far within the Tests stage. */
|
||||
readonly suitesDone: number;
|
||||
};
|
||||
|
||||
const buildFrames = (): readonly Frame[] => {
|
||||
const frames: Frame[] = [];
|
||||
for (const attempt of [1, 2] as const) {
|
||||
frames.push({ attempt, stagesDone: 0, suitesDone: 0 });
|
||||
frames.push({ attempt, stagesDone: 1, suitesDone: 0 });
|
||||
frames.push({ attempt, stagesDone: 2, suitesDone: 0 });
|
||||
for (let s = 1; s <= SUITES.length; s++) {
|
||||
frames.push({ attempt, stagesDone: 2, suitesDone: s });
|
||||
}
|
||||
frames.push({ attempt, stagesDone: 3, suitesDone: SUITES.length });
|
||||
frames.push({ attempt, stagesDone: 4, suitesDone: SUITES.length });
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
const FRAMES = buildFrames();
|
||||
const FRAME_MS = 420;
|
||||
const HOLD_AFTER_FAIL_MS = 3200;
|
||||
const FIRST_ATTEMPT_END = FRAMES.findIndex((f) => f.attempt === 2) - 1;
|
||||
|
||||
type StageState = "waiting" | "running" | "passed" | "failed" | "blocked";
|
||||
|
||||
const STAGE_STYLE: Readonly<Record<StageState, string>> = {
|
||||
waiting: "border-slate-700 text-slate-500",
|
||||
running: "border-slate-400 text-white",
|
||||
passed: "border-emerald-500/60 text-emerald-400",
|
||||
failed: "border-rose-500/70 text-rose-400",
|
||||
blocked: "border-slate-700 text-slate-500 line-through",
|
||||
};
|
||||
|
||||
const STAGE_MARK: Readonly<Record<StageState, string>> = {
|
||||
waiting: "·",
|
||||
running: "…",
|
||||
passed: "✓",
|
||||
failed: "✕",
|
||||
blocked: "—",
|
||||
};
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const PipelineRun: React.FC = () => {
|
||||
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
|
||||
const [frameIndex, setFrameIndex] = React.useState(0);
|
||||
const [run, setRun] = React.useState(0);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
|
||||
const scenario =
|
||||
SCENARIOS.find((item) => item.id === scenarioId) ?? SCENARIOS[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.25 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!inView) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setFrameIndex(FRAMES.length - 1);
|
||||
return;
|
||||
}
|
||||
setFrameIndex(0);
|
||||
let index = 0;
|
||||
let timer = 0;
|
||||
const tick = () => {
|
||||
index += 1;
|
||||
if (index >= FRAMES.length) return;
|
||||
setFrameIndex(index);
|
||||
// Stay on the blocked release long enough to read the failure.
|
||||
const delay = index === FIRST_ATTEMPT_END ? HOLD_AFTER_FAIL_MS : FRAME_MS;
|
||||
timer = window.setTimeout(tick, delay);
|
||||
};
|
||||
timer = window.setTimeout(tick, FRAME_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [inView, scenarioId, run]);
|
||||
|
||||
if (!scenario) return null;
|
||||
|
||||
const frame = FRAMES[frameIndex] ?? FRAMES[0];
|
||||
if (!frame) return null;
|
||||
|
||||
const firstAttempt = frame.attempt === 1;
|
||||
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
|
||||
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
|
||||
const finished = frame.stagesDone === STAGES.length;
|
||||
|
||||
const stageState = (index: number): StageState => {
|
||||
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
|
||||
return "failed";
|
||||
}
|
||||
if (index === 3 && firstAttempt && frame.stagesDone >= 3 && failureSeen) {
|
||||
return finished ? "blocked" : "waiting";
|
||||
}
|
||||
if (index < frame.stagesDone) return "passed";
|
||||
if (index === frame.stagesDone) return "running";
|
||||
return "waiting";
|
||||
};
|
||||
|
||||
const failedCount = failureSeen ? 1 : 0;
|
||||
const reportedTests = SUITES.slice(0, frame.suitesDone).reduce(
|
||||
(sum, suite) => sum + suite.tests,
|
||||
0,
|
||||
);
|
||||
|
||||
// Which of the three explanations applies right now.
|
||||
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
|
||||
|
||||
const EXPLANATIONS = [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SCENARIOS.map((item) => {
|
||||
const selected = item.id === scenario.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
setScenarioId(item.id);
|
||||
setRun((n) => n + 1);
|
||||
}}
|
||||
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
|
||||
{EXPLANATIONS.map((item, index) => {
|
||||
const active = index === explain;
|
||||
return (
|
||||
<li
|
||||
key={item.title}
|
||||
className={`border-t py-5 transition-colors duration-300 ${
|
||||
active ? "border-slate-900" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`m-0 font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
|
||||
active ? "text-slate-900" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p
|
||||
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||
active ? "text-slate-600" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRun((n) => n + 1)}
|
||||
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
|
||||
>
|
||||
Run again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
|
||||
{/* Run header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex gap-1.5" aria-hidden="true">
|
||||
<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>
|
||||
<span className="text-slate-400">
|
||||
{scenario.branch}
|
||||
<span className="text-slate-600"> → </span>main
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
run #{frame.attempt === 1 ? 481 : 482}
|
||||
{frame.attempt === 2 && (
|
||||
<span className="text-slate-400"> · fix pushed</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
|
||||
{STAGES.map((stage, index) => {
|
||||
const state = stageState(index);
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
|
||||
>
|
||||
<span>{stage}</span>
|
||||
<span aria-hidden="true">{STAGE_MARK[state]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Test output */}
|
||||
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
|
||||
{frame.stagesDone < 2 && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
{frame.stagesDone === 0
|
||||
? "$ lint: checking the code style…"
|
||||
: "$ types: checking that the parts fit together…"}
|
||||
</p>
|
||||
)}
|
||||
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
|
||||
const failed = firstAttempt && index === failingIndex;
|
||||
return (
|
||||
<div key={suite.file}>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
|
||||
>
|
||||
{failed ? "FAIL" : "PASS"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-slate-200">
|
||||
{suite.file}
|
||||
</span>
|
||||
<span className="hidden text-slate-500 sm:inline">
|
||||
{suite.tests} tests
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-slate-600">
|
||||
{suite.ms} ms
|
||||
</span>
|
||||
</div>
|
||||
{failed && (
|
||||
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
|
||||
● {scenario.testName}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
|
||||
expected {scenario.expected}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
|
||||
received {scenario.received}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
running {SUITES[frame.suitesDone]?.file}…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
|
||||
<span>
|
||||
<span className="text-slate-500">Tests </span>
|
||||
{failedCount > 0 && (
|
||||
<span className="text-rose-400">{failedCount} failed, </span>
|
||||
)}
|
||||
<span className="text-emerald-400">
|
||||
{reportedTests - failedCount} passed
|
||||
</span>
|
||||
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
|
||||
!finished
|
||||
? "bg-slate-800 text-slate-400"
|
||||
: firstAttempt
|
||||
? "bg-rose-500/15 text-rose-400"
|
||||
: "bg-emerald-500/15 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{!finished
|
||||
? "release pending"
|
||||
: firstAttempt
|
||||
? "release blocked"
|
||||
: "released"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
|
||||
An illustration with made-up file names and numbers, modelled on a
|
||||
real test run. Tests do not catch every mistake, but they do catch
|
||||
this kind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/** True on phone-sized screens. Starts false so server and first client render agree. */
|
||||
export const useNarrow = (): boolean => {
|
||||
const [narrow, setNarrow] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 767px)");
|
||||
const update = () => setNarrow(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return narrow;
|
||||
};
|
||||
Reference in New Issue
Block a user