Files
mintel.me/apps/web/src/components/illustrations/AutomationFlow.tsx
T
mmintelandClaude Opus 5.5 ca0eddfde8 feat: a shorter page on phones
On a phone the homepage was about 29 screens long; it is now about 22.
The desktop layout is unchanged to the pixel.

Lists of equal items (automation notes, project fit, principles, reasons for
a web presentation, by hand vs. automated) are swiped through side by side
(SwipeRow) instead of stacked. The four service cards form a compact 2x2
grid, step lists spell out only the current step, the workflow legend and
the per-project task lists are left out, and sections use less padding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:50:26 +02:00

463 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}>
<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-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>
<BitAura source="left" tone="light" className="mt-8">
<div className="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 ? automation.runComplete : automation.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="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>
</BitAura>
<SwipeRow 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`}>{automation.byHand}</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`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
</div>
</SwipeRow>
</div>
);
};