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>
463 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|