Dark surfaces now carry light: frames glow along their edge and shed binary digits (BitAura), dark sections open with a lit seam (BitSeam), the career path is a line of light and "My work" became a dark stage. Buttons carry a travelling light and a spot that follows the pointer (BitGlow). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1055 lines
25 KiB
TypeScript
1055 lines
25 KiB
TypeScript
"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";
|
|
/** A lit surface: a light tint instead of a solid white block. */
|
|
const TINT = "rgba(248, 250, 252, 0.14)";
|
|
const RIM = "#64748b";
|
|
const FINE = 1.1;
|
|
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={3.5}
|
|
fill={MARK}
|
|
stroke={MARK}
|
|
strokeOpacity={0.28}
|
|
strokeWidth={6}
|
|
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={130}
|
|
r={15}
|
|
fill={filled ? TINT : "none"}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<path
|
|
d={`M ${x - 28} 194 C ${x - 28} 172 ${x - 16} 160 ${x} 160 C ${x + 16} 160 ${x + 28} 172 ${x + 28} 194`}
|
|
fill={filled ? TINT : "none"}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
strokeLinecap="round"
|
|
/>
|
|
<line
|
|
x1={x - 36}
|
|
x2={x + 36}
|
|
y1={194}
|
|
y2={194}
|
|
stroke={LINE}
|
|
strokeWidth={1}
|
|
strokeLinecap="round"
|
|
/>
|
|
{filled && <circle cx={x + 12} cy={119} r={3.5} fill={MARK} />}
|
|
<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={24}
|
|
rx={12}
|
|
fill={b.side === "left" ? SOFT : TINT}
|
|
stroke={b.side === "left" ? LINE : INK}
|
|
strokeWidth={b.side === "left" ? 1 : FINE}
|
|
/>
|
|
<line
|
|
x1={b.x + 14}
|
|
x2={b.x + b.w - 14}
|
|
y1={b.y + 12}
|
|
y2={b.y + 12}
|
|
stroke={b.side === "left" ? "#94a3b8" : INK}
|
|
strokeWidth={1.5}
|
|
strokeLinecap="round"
|
|
/>
|
|
</motion.g>
|
|
))}
|
|
<motion.g {...appear(1.7)}>
|
|
<rect
|
|
x={270}
|
|
y={200}
|
|
width={60}
|
|
height={24}
|
|
rx={12}
|
|
fill={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
{[0, 1, 2].map((dot) => (
|
|
<motion.circle
|
|
key={dot}
|
|
cx={288 + dot * 12}
|
|
cy={212}
|
|
r={2.25}
|
|
fill={INK}
|
|
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={56 + d * 72}
|
|
y={66 + h * 21}
|
|
width={44}
|
|
height={10}
|
|
rx={5}
|
|
strokeWidth={1}
|
|
initial={{ fill: PAPER, stroke: LINE }}
|
|
animate={{
|
|
fill: reserved ? "#e2e8f0" : PAPER,
|
|
stroke: reserved ? "#e2e8f0" : 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={FINE}
|
|
/>
|
|
{done && (
|
|
<motion.path
|
|
d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`}
|
|
fill="none"
|
|
stroke={PAPER}
|
|
strokeWidth={1.5}
|
|
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={1.5}
|
|
strokeLinecap="round"
|
|
/>
|
|
{done && (
|
|
<motion.g {...appear(0.6 + row * 0.45, 0)}>
|
|
<path
|
|
d={`M 322 ${y + 16} h 46`}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
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={1.5} />
|
|
{Array.from({ length: 5 }, (_, w) => (
|
|
<g key={w}>
|
|
<line
|
|
x1={50 + w * 95}
|
|
x2={50 + w * 95}
|
|
y1={140}
|
|
y2={160}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<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={139}
|
|
height={22}
|
|
rx={5}
|
|
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={FINE}
|
|
/>
|
|
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
|
two weeks notice
|
|
</text>
|
|
</motion.g>
|
|
<motion.circle cx={430} cy={150} r={4.5} 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={FINE}
|
|
strokeDasharray="3 5"
|
|
/>
|
|
<motion.line
|
|
x1={from[0]}
|
|
y1={from[1]}
|
|
x2={to[0]}
|
|
y2={to[1]}
|
|
stroke={INK}
|
|
strokeWidth={1.5}
|
|
{...draw(0.4 + i * 0.22)}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
{NODES.map(([x, y], i) => (
|
|
<motion.circle
|
|
key={i}
|
|
cx={x}
|
|
cy={y}
|
|
r={5}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
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={FINE}
|
|
/>
|
|
{["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={2}
|
|
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={TINT}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<text
|
|
x={90}
|
|
y={154}
|
|
textAnchor="middle"
|
|
style={{
|
|
...MONO,
|
|
fill: INK,
|
|
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={FINE}
|
|
{...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={FINE}
|
|
/>
|
|
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={FINE} />
|
|
{[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 },
|
|
];
|
|
|
|
/** What a finished page holds: a headline, a button, and a line of text per card. */
|
|
const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
|
|
<motion.g {...appear(delay, 0)}>
|
|
<line
|
|
x1={102}
|
|
x2={206}
|
|
y1={118}
|
|
y2={118}
|
|
stroke={INK}
|
|
strokeWidth={3}
|
|
strokeLinecap="round"
|
|
/>
|
|
<line
|
|
x1={102}
|
|
x2={176}
|
|
y1={129}
|
|
y2={129}
|
|
stroke={INK}
|
|
strokeWidth={3}
|
|
strokeLinecap="round"
|
|
/>
|
|
<rect x={102} y={140} width={40} height={11} rx={5.5} fill={INK} />
|
|
{[BLOCKS[2], BLOCKS[3], BLOCKS[4], BLOCKS[5]].map(
|
|
(block) =>
|
|
block && (
|
|
<g key={`${block.x}-${block.y}`}>
|
|
<line
|
|
x1={block.x + 10}
|
|
x2={block.x + block.w - 24}
|
|
y1={block.y + block.h - 20}
|
|
y2={block.y + block.h - 20}
|
|
stroke={RIM}
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
/>
|
|
<line
|
|
x1={block.x + 10}
|
|
x2={block.x + block.w - 44}
|
|
y1={block.y + block.h - 12}
|
|
y2={block.y + block.h - 12}
|
|
stroke={LINE}
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
/>
|
|
</g>
|
|
),
|
|
)}
|
|
</motion.g>
|
|
);
|
|
|
|
/** 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 ? TINT : SOFT,
|
|
stroke: i === 1 ? INK : LINE,
|
|
}}
|
|
transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
|
|
/>
|
|
))}
|
|
<PageDetail delay={1.3} />
|
|
</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 ? TINT : SOFT}
|
|
stroke={i === 1 ? INK : LINE}
|
|
/>
|
|
))}
|
|
<PageDetail delay={0} />
|
|
<motion.rect
|
|
x={BLOCKS[4]!.x}
|
|
y={BLOCKS[4]!.y}
|
|
width={BLOCKS[4]!.w}
|
|
height={BLOCKS[4]!.h}
|
|
rx={5}
|
|
fill={MARK}
|
|
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={FINE}
|
|
/>
|
|
<path
|
|
d="M 16 190 l 23 16 l 23 -16"
|
|
fill="none"
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
</motion.g>
|
|
<motion.path
|
|
d="M 66 202 C 120 202 150 205 188 205"
|
|
fill="none"
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
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={SOFT}
|
|
stroke={RIM}
|
|
strokeWidth={FINE}
|
|
/>
|
|
<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={FINE}
|
|
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={FINE}
|
|
/>
|
|
<path
|
|
d="M 218 172 Q 218 146 240 146 Q 262 146 262 172"
|
|
fill={PAPER}
|
|
stroke={INK}
|
|
strokeWidth={FINE}
|
|
/>
|
|
</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={1.5}
|
|
{...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={1.5} />
|
|
))}
|
|
{[
|
|
{ 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={3.5}
|
|
fill={MARK}
|
|
stroke={MARK}
|
|
strokeOpacity={0.28}
|
|
strokeWidth={6}
|
|
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="5"
|
|
markerHeight="5"
|
|
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>
|
|
);
|
|
};
|