The homepage was too long and read as a list of sections. The technical ones (project fit, tools, how a change is secured) move to a page of their own, /freelance and /en/freelance, linked from the header, the footer and a pointer on the homepage. The rest is reordered into one story (who, what you need, examples, how working together goes, results, questions, contact) and each section opens with a sentence that leads over from the one before. routes.ts: a page may now have the same name in both languages. Before, /en/<german name> was always moved to the English name, which for such a page would have redirected the address onto itself. Design: list titles such as "Passt gut" are real headings instead of small grey labels. The automation examples, their description and the run are one box, so it is clear they belong together. Digits on light backgrounds are the blue of the panels instead of black. The square corner marks on the rounded service cards are gone. The years in the career line are readable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { BitAura } from "@/src/components/bits/BitAura";
|
|
import { SwipeRow } from "@/src/components/SwipeRow";
|
|
import * as React from "react";
|
|
import { useNarrow } from "./useNarrow";
|
|
import { useContent } from "@/src/components/ContentProvider";
|
|
import { workflowsFor, type FlowNode } from "@/src/content/automation";
|
|
import type { NodeKind } from "@/src/content/types";
|
|
|
|
/**
|
|
* 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" },
|
|
};
|
|
|
|
/** The order of the legend below the drawing. */
|
|
const KINDS: readonly NodeKind[] = ["trigger", "rule", "ai", "action", "human"];
|
|
|
|
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 { automation } = useContent();
|
|
const workflows = React.useMemo(
|
|
() => workflowsFor(automation.workflows),
|
|
[automation.workflows],
|
|
);
|
|
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: { text: string; kind: NodeKind }[] = [];
|
|
const log = (node: Placed) => {
|
|
if (logLines.every((line) => line.text !== node.log)) {
|
|
logLines.push({ text: node.log, kind: node.kind });
|
|
}
|
|
};
|
|
if (firstNode) {
|
|
const first = nodeOf(firstNode);
|
|
if (first) log(first);
|
|
}
|
|
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) log(node);
|
|
});
|
|
});
|
|
const finished = clock.step >= steps.length;
|
|
|
|
return (
|
|
<div ref={ref}>
|
|
{/* The examples, their description and the run are one box: they belong together. */}
|
|
<BitAura source="left" tone="light">
|
|
<div className="overflow-hidden rounded-3xl bg-slate-900">
|
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-800 px-5 py-4 md:px-7">
|
|
<div
|
|
role="tablist"
|
|
aria-label={automation.examplesLabel}
|
|
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-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
|
selected
|
|
? "border-white bg-white text-slate-900"
|
|
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
|
}`}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<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 ? automation.runComplete : automation.running}
|
|
</span>
|
|
</div>
|
|
<p className="m-0 max-w-4xl px-5 pb-2 pt-6 font-sans text-lg leading-relaxed text-slate-200 md:px-7 md:pt-8 md:text-xl">
|
|
{flow.summary}
|
|
</p>
|
|
|
|
<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="px-5 py-5 md:col-span-7 md:border-r md:border-slate-800 md:px-7">
|
|
<p className={`${MONO} m-0 text-slate-500`}>{automation.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.text} className="flex gap-3">
|
|
<span className="text-slate-600">
|
|
{String(index + 1).padStart(2, "0")}
|
|
</span>
|
|
<span
|
|
className={
|
|
line.kind === "human" ? "text-amber-200" : undefined
|
|
}
|
|
>
|
|
{line.text}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
<ul className="m-0 hidden list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:flex md:px-7">
|
|
{KINDS.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>
|
|
{automation.kinds[kind]}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="border-t border-slate-800 px-5 py-6 md:px-7 md:py-8">
|
|
<SwipeRow className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
|
|
<div>
|
|
<p className={`${MONO} m-0 text-slate-500`}>
|
|
{automation.byHand}
|
|
</p>
|
|
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-400 md:text-lg">
|
|
{flow.byHand}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className={`${MONO} m-0 text-emerald-400`}>
|
|
{automation.automated}
|
|
</p>
|
|
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-white md:text-lg">
|
|
{flow.automated}
|
|
</p>
|
|
</div>
|
|
</SwipeRow>
|
|
</div>
|
|
</div>
|
|
</BitAura>
|
|
</div>
|
|
);
|
|
};
|