feat: drawings in circuit style, visible hero digits, hourly booking only on the freelance page

Drawings: "How I work" and the step drawings under "How it runs" now use
the same language as the story thread. Systems and people are chips with
pins, connections are tracks with 45 degree bends and solder points, and
what travels is a bit instead of a round dot (circuit.tsx).

Hero: the digits in the background were close to invisible on white. They
are about twice as strong now, and the ones near the pointer light up indigo.

Homepage vs. freelance page: the homepage is for clients who buy a result,
so it shows three cases. The developer case (100 EUR per hour, booked by the
month) and its steps live on the freelance page only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 11:37:26 +02:00
co-authored by Claude Opus 5.5
parent affd4b7ee7
commit f0c25b802b
11 changed files with 430 additions and 294 deletions
+5
View File
@@ -72,6 +72,11 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
(owner rejected both). (owner rejected both).
- Drawings use the circuit parts in `apps/web/src/components/illustrations/circuit.tsx`: `Chip` (a system
or person, with pins), `Track` (straight runs, 45 degree bends), `Pad` (solder point), `Bit` (what travels).
New drawings are built from these, not from pills, curves and round dots.
- The homepage is for clients who buy a result (website, automation, a reliable app). Booking by the hour
for teams (the `developer` path, 100 EUR per hour, by the month) is shown only on the freelance page.
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump. - `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element - Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
never counts as visible. Observe an unclipped wrapper (see `BitWipe`). never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
+15 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText"; import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block"; import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta"; import { ClosingCta } from "@/src/components/home/ClosingCta";
import { PathStory } from "@/src/components/home/PathPicker";
import { TopicButton } from "@/src/components/home/TopicButton"; import { TopicButton } from "@/src/components/home/TopicButton";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun"; import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import { Reveal } from "@/src/components/Reveal"; import { Reveal } from "@/src/components/Reveal";
@@ -38,6 +39,8 @@ export default async function FreelancePage({ params }: Props) {
const content = contentFor(locale); const content = contentFor(locale);
const { freelance, technology } = content; const { freelance, technology } = content;
const { booking } = freelance; const { booking } = freelance;
const { services } = content;
const teamPath = services.paths.find((path) => path.id === "developer");
return ( return (
<div className="relative flex flex-col bg-white"> <div className="relative flex flex-col bg-white">
@@ -137,8 +140,19 @@ export default async function FreelancePage({ params }: Props) {
</Reveal> </Reveal>
</Block> </Block>
{teamPath && (
<Block index="03" label={services.howItRuns} title={teamPath.headline}>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>{teamPath.intro}</p>
</Reveal>
<Reveal>
<PathStory path={teamPath} active />
</Reveal>
</Block>
)}
<Block <Block
index="03" index="04"
label={booking.label} label={booking.label}
title={ title={
<> <>
@@ -58,6 +58,11 @@ export const AbstractCircuit: React.FC<{
const GAP = 20; const GAP = 20;
const PHONE_WIDTH = 640; const PHONE_WIDTH = 640;
const PHONE_INK = 2.4; const PHONE_INK = 2.4;
// On white the digits were close to invisible (owner feedback).
const DESKTOP_INK = 2.1;
/** Digits near the pointer light up in the indigo of the site. */
const POINTER_RADIUS = 190;
let pointer: { x: number; y: number } | null = null;
const PHONE_GROW = 2; const PHONE_GROW = 2;
const seed = () => { const seed = () => {
@@ -66,7 +71,7 @@ export const AbstractCircuit: React.FC<{
// On a phone the field is small and held close: the digits need more // On a phone the field is small and held close: the digits need more
// size and ink there, or they are not seen at all. // size and ink there, or they are not seen at all.
const small = w < PHONE_WIDTH; const small = w < PHONE_WIDTH;
const ink = small ? PHONE_INK : 1; const ink = small ? PHONE_INK : DESKTOP_INK;
const grow = small ? PHONE_GROW : 0; const grow = small ? PHONE_GROW : 0;
// Horizontal lanes // Horizontal lanes
@@ -148,14 +153,24 @@ export const AbstractCircuit: React.FC<{
const breath = Math.sin(t + c.phase) * 0.015; const breath = Math.sin(t + c.phase) * 0.015;
let alpha = c.baseAlpha + breath; let alpha = c.baseAlpha + breath;
const near = pointer
? Math.max(
0,
1 - Math.hypot(c.x - pointer.x, c.y - pointer.y) / POINTER_RADIUS,
)
: 0;
alpha += near * 0.75;
// ── Draw ── // ── Draw ──
const sz = c.size; const sz = c.size;
ctx.font = `bold ${sz}px "SF Mono", "Fira Code", "Cascadia Code", "Menlo", monospace`; ctx.font = `bold ${sz}px "SF Mono", "Fira Code", "Cascadia Code", "Menlo", monospace`;
const shade = Math.min(1, Math.max(alpha, 0));
ctx.fillStyle = invert ctx.fillStyle = invert
? `rgba(255,255,255,${Math.max(alpha, 0)})` ? `rgba(255,255,255,${shade})`
: `rgba(100,116,139,${Math.max(alpha, 0)})`; : near > 0.05
? `rgba(79,70,229,${shade})`
: `rgba(100,116,139,${shade})`;
ctx.shadowColor = "transparent"; ctx.shadowColor = "transparent";
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -167,7 +182,15 @@ export const AbstractCircuit: React.FC<{
// ── Event listeners ── // ── Event listeners ──
// Listen on the PARENT element (the section) to capture all mouse movement // Listen on the PARENT element (the section) to capture all mouse movement
const onPointerMove = (event: PointerEvent) => {
const box = canvas.getBoundingClientRect();
pointer =
event.pointerType === "mouse"
? { x: event.clientX - box.left, y: event.clientY - box.top }
: null;
};
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
window.addEventListener("pointermove", onPointerMove, { passive: true });
resize(); resize();
raf = requestAnimationFrame(render); raf = requestAnimationFrame(render);
@@ -175,6 +198,7 @@ export const AbstractCircuit: React.FC<{
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
window.removeEventListener("resize", resize); window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointerMove);
}; };
}, [invert]); }, [invert]);
@@ -8,7 +8,7 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero, hero,
}) => ( }) => (
<section className="relative overflow-hidden bg-white"> <section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70"> <div className="absolute inset-0">
<AbstractCircuit /> <AbstractCircuit />
</div> </div>
+9 -15
View File
@@ -2,23 +2,26 @@
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura"; import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText"; import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes"; import { PathScene } from "@/src/components/illustrations/PathScenes";
import { useContent, useLocale } from "@/src/components/ContentProvider"; import { useContent } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
import { TopicButton } from "./TopicButton"; import { TopicButton } from "./TopicButton";
import type { ServicePath } from "@/src/content/types"; import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500"; const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800; const STEP_MS = 4800;
/**
* Booked by the hour by a team, not bought as a result: this case is not on the
* homepage. It lives on the freelance page.
*/
const TEAM_PATH = "developer";
/** /**
* PathStory: the four steps of one path, each with a drawing that explains it. * PathStory: the four steps of one path, each with a drawing that explains it.
* The steps advance on their own until the visitor picks one. * The steps advance on their own until the visitor picks one.
*/ */
const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({ export const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
path, path,
active, active,
}) => { }) => {
@@ -151,8 +154,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
export const PathPicker: React.FC = () => { export const PathPicker: React.FC = () => {
const { services } = useContent(); const { services } = useContent();
const { estimate } = services; const { estimate } = services;
const locale = useLocale(); const paths = services.paths.filter((path) => path.id !== TEAM_PATH);
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? ""); const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return ( return (
@@ -160,7 +162,7 @@ export const PathPicker: React.FC = () => {
<div <div
role="tablist" role="tablist"
aria-label={services.tablistLabel} aria-label={services.tablistLabel}
className="grid grid-cols-2 gap-2 md:gap-3 lg:grid-cols-4 lg:gap-4" className="grid grid-cols-1 gap-2 md:grid-cols-3 md:gap-4"
> >
{paths.map((path, index) => { {paths.map((path, index) => {
const selected = path.id === activeId; const selected = path.id === activeId;
@@ -285,14 +287,6 @@ export const PathPicker: React.FC = () => {
</p> </p>
<div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8"> <div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8">
<TopicButton topic={path.id}>{estimate.cta}</TopicButton> <TopicButton topic={path.id}>{estimate.cta}</TopicButton>
{path.id === "developer" && (
<Link
href={pathFor("freelance", locale)}
className="border-b border-slate-900 pb-0.5 font-sans text-base font-bold text-slate-900 no-underline transition-opacity hover:opacity-60"
>
{estimate.freelance} →
</Link>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1,52 +1,11 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider"; import { useContent } from "@/src/components/ContentProvider";
import { Bit, Chip, CIRCUIT, Pad, Track } from "./circuit";
import { useNarrow } from "./useNarrow"; import { useNarrow } from "./useNarrow";
const INK = "#f8fafc"; const CHIP = { width: 104, height: 36 } as const;
const LINE = "#475569";
const MARK = "#fde68a";
const NODE_W = 112;
const NODE_H = 38;
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<{ const Caption: React.FC<{
x: number; x: number;
@@ -63,35 +22,40 @@ const Caption: React.FC<{
fontSize: size, fontSize: size,
letterSpacing: "0.12em", letterSpacing: "0.12em",
textTransform: "uppercase", textTransform: "uppercase",
fill: strong ? INK : "#64748b", fill: strong ? CIRCUIT.ink : "#64748b",
}} }}
> >
{children} {children}
</text> </text>
); );
/** The signal gets weaker with every hand it passes through. */
const fading = (stops: readonly number[]): number[] =>
stops.map((_, i) => 0.95 - (i / stops.length) * 0.75);
/** /**
* ContactDiagram: a request passing slowly from hand to hand, next to the direct * ContactDiagram: a request passing slowly from chip to chip along a broken
* line you get when you talk to the person who does the work. Wide screens show * track and losing strength, next to the direct track you get when you talk to
* the two lanes as rows, phones as two columns. * the person who does the work. Wide screens show the two lanes as rows, phones
* as two columns.
*/ */
export const ContactDiagram: React.FC = () => { export const ContactDiagram: React.FC = () => {
const tall = useNarrow(); const tall = useNarrow();
const copy = useContent().working.diagram; const copy = useContent().working.diagram;
const chain = copy.chain; const chain = copy.chain;
const label = copy.description;
if (tall) { if (tall) {
// Two columns: the chain on the left, the direct line on the right. // Two columns: the chain on the left, the direct track on the right.
const ys = chain.map((_, i) => 96 + i * 84); const ys = chain.map((_, i) => 96 + i * 84);
const first = ys[0] ?? 96; const first = ys[0] ?? 96;
const last = ys[ys.length - 1] ?? 432; const last = ys[ys.length - 1] ?? 432;
const stops = ys.flatMap((y) => [y, y]); const stops = ys.flatMap((y) => [y, y]);
const edge = CHIP.height / 2;
return ( return (
<svg <svg
viewBox="0 0 340 500" viewBox="0 0 340 500"
role="img" role="img"
aria-label={label} aria-label={copy.description}
className="block h-auto w-full" className="block h-auto w-full"
> >
<Caption x={24} y={38} size={12}> <Caption x={24} y={38} size={12}>
@@ -100,60 +64,47 @@ export const ContactDiagram: React.FC = () => {
<Caption x={196} y={38} size={12} strong> <Caption x={196} y={38} size={12} strong>
{copy.withMe} {copy.withMe}
</Caption> </Caption>
<line
x1={90} <Track d={`M88 ${first}V${last}`} muted />
x2={90}
y1={first}
y2={last}
stroke={LINE}
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{chain.map((name, i) => ( {chain.map((name, i) => (
<Node <Chip
key={name} key={name}
x={90} x={88}
y={ys[i] ?? 0} y={ys[i] ?? 0}
{...CHIP}
label={name} label={name}
size={12} size={11}
muted={i > 0} tone={i > 0 ? "muted" : "line"}
pins={3}
/> />
))} ))}
<motion.circle <Bit
r={5} xs={stops.map(() => 156)}
cx={160} ys={stops}
fill={MARK} opacity={fading(stops)}
initial={{ cy: first }} duration={9}
animate={{ pause={0.8}
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} <Track d={`M252 ${first + edge}V${last - edge}`} bus />
x2={250} <Chip x={252} y={first} {...CHIP} label={copy.you} size={11} pins={3} />
y1={first} <Chip
y2={last} x={252}
stroke={INK} y={last}
strokeWidth={2} {...CHIP}
label={copy.me}
size={11}
tone="lit"
pins={3}
/> />
<Node x={250} y={first} label={copy.you} size={12} /> <Pad x={252} y={first + edge} />
<Node x={250} y={last} label={copy.me} size={12} solid /> <Pad x={252} y={last - edge} lit />
<motion.circle <Bit
r={5} xs={[252, 252, 252]}
cx={250} ys={[first + edge + 10, last - edge - 10, first + edge + 10]}
fill={MARK} opacity={[1, 1, 1]}
initial={{ cy: first + NODE_H / 2 }} duration={2.4}
animate={{ pause={0}
cy: [first + NODE_H / 2, last - NODE_H / 2, first + NODE_H / 2],
}}
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
/> />
</svg> </svg>
); );
@@ -163,11 +114,12 @@ export const ContactDiagram: React.FC = () => {
const first = xs[0] ?? 76; const first = xs[0] ?? 76;
const last = xs[xs.length - 1] ?? 604; const last = xs[xs.length - 1] ?? 604;
const stops = xs.flatMap((x) => [x, x]); const stops = xs.flatMap((x) => [x, x]);
const edge = CHIP.width / 2;
return ( return (
<svg <svg
viewBox="0 0 680 250" viewBox="0 0 680 250"
role="img" role="img"
aria-label={label} aria-label={copy.description}
className="block h-auto w-full" className="block h-auto w-full"
> >
<defs> <defs>
@@ -182,67 +134,43 @@ export const ContactDiagram: React.FC = () => {
</defs> </defs>
<rect width="680" height="250" fill="url(#contact-grid)" /> <rect width="680" height="250" fill="url(#contact-grid)" />
<Caption x={20} y={30} size={10}> <Caption x={20} y={26} size={10}>
{copy.oftenLong} {copy.oftenLong}
</Caption> </Caption>
<line <Track d={`M${first} 82H${last}`} muted />
x1={first}
x2={last}
y1={74}
y2={74}
stroke={LINE}
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{chain.map((name, i) => ( {chain.map((name, i) => (
<Node <Chip
key={name} key={name}
x={xs[i] ?? 0} x={xs[i] ?? 0}
y={74} y={82}
{...CHIP}
label={name} label={name}
size={10} size={10}
muted={i > 0} tone={i > 0 ? "muted" : "line"}
/> />
))} ))}
<motion.circle <Bit
r={5} xs={stops}
cy={46} ys={stops.map(() => 46)}
fill={MARK} opacity={fading(stops)}
initial={{ cx: first }} duration={9}
animate={{ pause={0.8}
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> <Caption x={20} y={160} size={10} strong>
{copy.withMeLong} {copy.withMeLong}
</Caption> </Caption>
<line <Track d={`M${first + edge} 204H${last - edge}`} bus />
x1={first} <Chip x={first} y={204} {...CHIP} label={copy.you} size={10} />
x2={last} <Chip x={last} y={204} {...CHIP} label={copy.me} size={10} tone="lit" />
y1={204} <Pad x={first + edge} y={204} />
y2={204} <Pad x={last - edge} y={204} lit />
stroke={INK} <Bit
strokeWidth={2} xs={[first + edge + 14, last - edge - 14, first + edge + 14]}
/> ys={[204, 204, 204]}
<Node x={first} y={204} label={copy.you} size={10} /> opacity={[1, 1, 1]}
<Node x={last} y={204} label={copy.me} size={10} solid /> duration={2.4}
<motion.circle pause={0}
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> </svg>
); );
@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Bit, Chip, Pad, Track } from "./circuit";
import { useContent } from "@/src/components/ContentProvider"; import { useContent } from "@/src/components/ContentProvider";
import type { SceneLabels } from "@/src/content/types"; import type { SceneLabels } from "@/src/content/types";
import { fill } from "@/src/domain/template"; import { fill } from "@/src/domain/template";
@@ -48,29 +49,6 @@ type Scene = React.FC<{ l: SceneLabels }>;
type Pt = readonly [number, number]; 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. */ /** A small packet that keeps travelling a route. */
const Packet: React.FC<{ const Packet: React.FC<{
xs: readonly number[]; xs: readonly number[];
@@ -78,28 +56,7 @@ const Packet: React.FC<{
delay?: number; delay?: number;
duration?: number; duration?: number;
pause?: number; pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => ( }> = (props) => <Bit {...props} />;
<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 }> = ({ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
x, x,
@@ -544,44 +501,45 @@ const Decide: Scene = ({ l }) => (
> >
{l.you} {l.you}
</text> </text>
<Pad x={116} y={150} />
{[70, 150, 230] {[70, 150, 230]
.map((y, i) => ({ y, label: l.options[i] ?? "" })) .map((y, i) => ({ y, label: l.options[i] ?? "" }))
.map((option, i) => ( .map((option, i) => {
<g key={option.label}> // Straight out, one 45 degree bend, straight in.
<motion.path const bend = Math.abs(option.y - 150);
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`} const turn = 150 + bend;
fill="none" return (
stroke={INK} <g key={option.label}>
strokeWidth={FINE} <motion.path
{...draw(0.3 + i * 0.4)} d={`M116 150H150L${turn} ${option.y}H250`}
/> fill="none"
<Packet stroke={INK}
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])} strokeWidth={1.4}
delay={1.6 + i * 1.3} strokeLinecap="square"
duration={1.1} {...draw(0.3 + i * 0.4)}
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 <Packet
x={340} xs={[116, 150, turn, 250]}
y={option.y + 4} ys={[150, 150, option.y, option.y]}
textAnchor="middle" delay={1.6 + i * 1.3}
style={{ ...MONO, fill: INK }} duration={1.1}
> pause={2.8}
{option.label} />
</text> <motion.g {...appear(0.7 + i * 0.4, 0)}>
</motion.g> <Chip
</g> x={340}
))} y={option.y}
width={180}
height={32}
label={option.label}
size="var(--scene-label, 10px)"
pins={6}
/>
<Pad x={250} y={option.y} />
</motion.g>
</g>
);
})}
</g> </g>
); );
@@ -852,21 +810,34 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
y, y,
label, label,
}) => ( }) => (
<g> <Chip
<rect x={x}
x={x - 56} y={y}
y={y - 22} width={112}
width={112} height={44}
height={44} label={label}
rx={10} size="var(--scene-label, 10px)"
fill={SOFT} />
stroke={RIM} );
strokeWidth={FINE}
/> /** Where the tracks between the tools end. */
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}> const TOOL_PADS: readonly Pt[] = [
{label} [146, 80],
</text> [334, 80],
</g> [90, 102],
[90, 178],
[390, 102],
[390, 178],
[146, 200],
[334, 200],
];
const ToolPads: React.FC<{ delay: number }> = ({ delay }) => (
<motion.g {...appear(delay, 0)}>
{TOOL_PADS.map(([x, y]) => (
<Pad key={`${x}-${y}`} x={x} y={y} />
))}
</motion.g>
); );
/** Automation, step 2: today a person carries the data from tool to tool. */ /** Automation, step 2: today a person carries the data from tool to tool. */
@@ -937,6 +908,7 @@ const Wiring: Scene = ({ l }) => (
{TOOLS.map((tool, i) => ( {TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} /> <Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))} ))}
<ToolPads delay={1.9} />
<motion.text <motion.text
x={240} x={240}
y={268} y={268}
@@ -950,42 +922,35 @@ const Wiring: Scene = ({ l }) => (
); );
/** Automation, step 4: the work travels between the systems on its own. */ /** Automation, step 4: the work travels between the systems on its own. */
/** The four runs between the tools, each as from x, from y, to x, to y. */
const RUNS: readonly (readonly [number, number, number, number])[] = [
[146, 80, 334, 80],
[390, 102, 390, 178],
[334, 200, 146, 200],
[90, 178, 90, 102],
];
const Running: Scene = ({ l }) => ( const Running: Scene = ({ l }) => (
<g> <g>
{WIRES.map((d) => ( {WIRES.map((d) => (
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} /> <Track key={d} d={d} bus />
))}
{[
{ 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, i) => ( {TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} /> <Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))} ))}
<ToolPads delay={0} />
{RUNS.map(([fx, fy, tx, ty], i) => (
<Bit
key={i}
xs={[fx, fx, tx, tx]}
ys={[fy, fy, ty, ty]}
opacity={[0, 1, 1, 0]}
delay={i * 0.7}
duration={1.4}
pause={1.4}
value={i % 2 === 0 ? 1 : 0}
/>
))}
<motion.text <motion.text
x={240} x={240}
y={268} y={268}
@@ -0,0 +1,210 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
/**
* The parts the drawings share with the story thread: everything is laid out
* like a circuit board. Systems and people are chips with pins, connections are
* tracks with solder points, and what travels along them is a bit.
*/
export const CIRCUIT = {
ink: "#f8fafc",
ground: "#0f172a",
line: "#475569",
rim: "#64748b",
soft: "#1e293b",
mark: "#fde68a",
live: "#10b981",
light: "#a5b4fc",
tint: "rgba(248, 250, 252, 0.14)",
} as const;
const MONO = "var(--font-jetbrains-mono), ui-monospace, monospace";
const PIN = 5;
type ChipTone = "line" | "lit" | "muted";
/** A system or a person: a chip with pins on its long sides and a label. */
export const Chip: React.FC<{
x: number;
y: number;
width: number;
height: number;
label: string;
/** Font size as a number or a CSS value. */
size: number | string;
tone?: ChipTone;
/** Pins per long side. */
pins?: number;
}> = ({ x, y, width, height, label, size, tone = "line", pins = 4 }) => {
const muted = tone === "muted";
const stroke = muted ? CIRCUIT.line : CIRCUIT.ink;
const left = x - width / 2;
const top = y - height / 2;
return (
<g>
{Array.from({ length: pins }, (_, i) => {
const px = left + ((i + 1) * width) / (pins + 1);
return (
<path
key={i}
d={`M${px} ${top}v${-PIN}M${px} ${top + height}v${PIN}`}
stroke={muted ? CIRCUIT.line : CIRCUIT.rim}
strokeWidth={1.25}
/>
);
})}
<rect
x={left}
y={top}
width={width}
height={height}
rx={4}
fill={tone === "lit" ? CIRCUIT.tint : CIRCUIT.ground}
stroke={stroke}
strokeWidth={tone === "lit" ? 1.5 : 1.1}
strokeDasharray={muted ? "4 4" : undefined}
style={
tone === "lit"
? { filter: "drop-shadow(0 0 6px rgba(165, 180, 252, 0.7))" }
: undefined
}
/>
{/* The notch that marks pin one. */}
<circle
cx={left + 7}
cy={top + 7}
r={1.6}
fill={muted ? CIRCUIT.line : CIRCUIT.rim}
/>
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: MONO,
fontSize: size,
letterSpacing: "0.12em",
textTransform: "uppercase",
fill: muted ? "#94a3b8" : CIRCUIT.ink,
}}
>
{label}
</text>
</g>
);
};
/** A solder point: where a track ends, bends or meets a chip. */
export const Pad: React.FC<{
x: number;
y: number;
muted?: boolean;
lit?: boolean;
}> = ({ x, y, muted = false, lit = false }) => (
<circle
cx={x}
cy={y}
r={3.2}
fill={lit ? CIRCUIT.light : CIRCUIT.ground}
stroke={muted ? CIRCUIT.line : CIRCUIT.ink}
strokeWidth={1.25}
/>
);
/**
* A bit travelling a route, again and again. `xs` and `ys` are the stops; it
* fades in after the first and out before the last unless `opacity` says otherwise.
*/
export const Bit: React.FC<{
xs: readonly number[];
ys: readonly number[];
opacity?: readonly number[];
delay?: number;
duration?: number;
pause?: number;
/** The digit it shows. */
value?: 0 | 1;
}> = ({
xs,
ys,
opacity,
delay = 0,
duration = 1.2,
pause = 1.4,
value = 1,
}) => (
<motion.g
initial={{ x: xs[0], y: ys[0], opacity: 0 }}
animate={{
x: [...xs],
y: [...ys],
opacity: opacity
? [...opacity]
: xs.map((_, i) => (i === 0 || i === xs.length - 1 ? 0 : 1)),
}}
transition={{
duration,
delay,
repeat: Infinity,
repeatDelay: pause,
ease: "easeInOut",
}}
>
<rect
x={-6}
y={-6}
width={12}
height={12}
rx={2}
fill={CIRCUIT.mark}
style={{ filter: "drop-shadow(0 0 6px rgba(251, 191, 36, 0.9))" }}
/>
<text
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: MONO,
fontSize: 9,
fontWeight: 700,
fill: CIRCUIT.ground,
}}
>
{value}
</text>
</motion.g>
);
/** A track: straight runs and 45 degree bends, with a second fainter track beside it. */
export const Track: React.FC<{
d: string;
muted?: boolean;
/** Draw the parallel second track. */
bus?: boolean;
}> = ({ d, muted = false, bus = false }) => (
<g fill="none" strokeLinecap="square" strokeLinejoin="miter">
{bus && (
<path
d={d}
stroke={CIRCUIT.light}
strokeWidth={1}
opacity={0.35}
transform="translate(4 4)"
/>
)}
<path
d={d}
stroke={muted ? CIRCUIT.line : CIRCUIT.ink}
strokeWidth={muted ? 1.25 : 1.6}
strokeDasharray={muted ? "4 4" : undefined}
style={
muted
? undefined
: { filter: "drop-shadow(0 0 4px rgba(165, 180, 252, 0.6))" }
}
/>
</g>
);
+1 -2
View File
@@ -74,7 +74,7 @@ export const de: Content = {
}, },
services: { services: {
label: "Leistungen", label: "Leistungen",
title: { lead: "Was brauchen Sie?", rest: "Vier typische Fälle." }, title: { lead: "Was brauchen Sie?", rest: "Drei typische Fälle." },
tablistLabel: "Was brauchen Sie?", tablistLabel: "Was brauchen Sie?",
youGet: "Das bekommen Sie", youGet: "Das bekommen Sie",
howItRuns: "So läuft es ab", howItRuns: "So läuft es ab",
@@ -98,7 +98,6 @@ export const de: Content = {
orientationLabel: "Zur Orientierung", orientationLabel: "Zur Orientierung",
net: "Alle Preise netto.", net: "Alle Preise netto.",
cta: "Schätzung anfragen", cta: "Schätzung anfragen",
freelance: "Buchung und Preise für Teams",
}, },
paths: [ paths: [
{ {
+1 -2
View File
@@ -73,7 +73,7 @@ export const en: Content = {
}, },
services: { services: {
label: "Services", label: "Services",
title: { lead: "What do you need?", rest: "Four typical cases." }, title: { lead: "What do you need?", rest: "Three typical cases." },
tablistLabel: "What do you need?", tablistLabel: "What do you need?",
youGet: "What you get", youGet: "What you get",
howItRuns: "How it runs", howItRuns: "How it runs",
@@ -97,7 +97,6 @@ export const en: Content = {
orientationLabel: "For orientation", orientationLabel: "For orientation",
net: "All prices net.", net: "All prices net.",
cta: "Ask for an estimate", cta: "Ask for an estimate",
freelance: "Booking and rates for teams",
}, },
paths: [ paths: [
{ {
-2
View File
@@ -197,8 +197,6 @@ export type Content = {
readonly orientationLabel: string; readonly orientationLabel: string;
readonly net: string; readonly net: string;
readonly cta: string; readonly cta: string;
/** The pointer to the booking terms on the freelance page. */
readonly freelance: string;
}; };
readonly paths: readonly ServicePath[]; readonly paths: readonly ServicePath[];
readonly scenes: SceneLabels; readonly scenes: SceneLabels;