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:
@@ -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
|
||||
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
|
||||
(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.
|
||||
- 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`).
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { PathStory } from "@/src/components/home/PathPicker";
|
||||
import { TopicButton } from "@/src/components/home/TopicButton";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
@@ -38,6 +39,8 @@ export default async function FreelancePage({ params }: Props) {
|
||||
const content = contentFor(locale);
|
||||
const { freelance, technology } = content;
|
||||
const { booking } = freelance;
|
||||
const { services } = content;
|
||||
const teamPath = services.paths.find((path) => path.id === "developer");
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
@@ -137,8 +140,19 @@ export default async function FreelancePage({ params }: Props) {
|
||||
</Reveal>
|
||||
</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
|
||||
index="03"
|
||||
index="04"
|
||||
label={booking.label}
|
||||
title={
|
||||
<>
|
||||
|
||||
@@ -58,6 +58,11 @@ export const AbstractCircuit: React.FC<{
|
||||
const GAP = 20;
|
||||
const PHONE_WIDTH = 640;
|
||||
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 seed = () => {
|
||||
@@ -66,7 +71,7 @@ export const AbstractCircuit: React.FC<{
|
||||
// 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.
|
||||
const small = w < PHONE_WIDTH;
|
||||
const ink = small ? PHONE_INK : 1;
|
||||
const ink = small ? PHONE_INK : DESKTOP_INK;
|
||||
const grow = small ? PHONE_GROW : 0;
|
||||
|
||||
// Horizontal lanes
|
||||
@@ -148,14 +153,24 @@ export const AbstractCircuit: React.FC<{
|
||||
const breath = Math.sin(t + c.phase) * 0.015;
|
||||
|
||||
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 ──
|
||||
const sz = c.size;
|
||||
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
|
||||
? `rgba(255,255,255,${Math.max(alpha, 0)})`
|
||||
: `rgba(100,116,139,${Math.max(alpha, 0)})`;
|
||||
? `rgba(255,255,255,${shade})`
|
||||
: near > 0.05
|
||||
? `rgba(79,70,229,${shade})`
|
||||
: `rgba(100,116,139,${shade})`;
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
@@ -167,7 +182,15 @@ export const AbstractCircuit: React.FC<{
|
||||
|
||||
// ── Event listeners ──
|
||||
// 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("pointermove", onPointerMove, { passive: true });
|
||||
|
||||
resize();
|
||||
raf = requestAnimationFrame(render);
|
||||
@@ -175,6 +198,7 @@ export const AbstractCircuit: React.FC<{
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
};
|
||||
}, [invert]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
|
||||
hero,
|
||||
}) => (
|
||||
<section className="relative overflow-hidden bg-white">
|
||||
<div className="absolute inset-0 opacity-70">
|
||||
<div className="absolute inset-0">
|
||||
<AbstractCircuit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { TopicButton } from "./TopicButton";
|
||||
import type { ServicePath } from "@/src/content/types";
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
||||
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.
|
||||
* 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,
|
||||
active,
|
||||
}) => {
|
||||
@@ -151,8 +154,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
export const PathPicker: React.FC = () => {
|
||||
const { services } = useContent();
|
||||
const { estimate } = services;
|
||||
const locale = useLocale();
|
||||
const paths = services.paths;
|
||||
const paths = services.paths.filter((path) => path.id !== TEAM_PATH);
|
||||
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
|
||||
|
||||
return (
|
||||
@@ -160,7 +162,7 @@ export const PathPicker: React.FC = () => {
|
||||
<div
|
||||
role="tablist"
|
||||
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) => {
|
||||
const selected = path.id === activeId;
|
||||
@@ -285,14 +287,6 @@ export const PathPicker: React.FC = () => {
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8">
|
||||
<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>
|
||||
|
||||
@@ -1,52 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { Bit, Chip, CIRCUIT, Pad, Track } from "./circuit";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
|
||||
const INK = "#f8fafc";
|
||||
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 CHIP = { width: 104, height: 36 } as const;
|
||||
|
||||
const Caption: React.FC<{
|
||||
x: number;
|
||||
@@ -63,35 +22,40 @@ const Caption: React.FC<{
|
||||
fontSize: size,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fill: strong ? INK : "#64748b",
|
||||
fill: strong ? CIRCUIT.ink : "#64748b",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</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
|
||||
* line you get when you talk to the person who does the work. Wide screens show
|
||||
* the two lanes as rows, phones as two columns.
|
||||
* ContactDiagram: a request passing slowly from chip to chip along a broken
|
||||
* track and losing strength, next to the direct track you get when you talk to
|
||||
* the person who does the work. Wide screens show the two lanes as rows, phones
|
||||
* as two columns.
|
||||
*/
|
||||
export const ContactDiagram: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const copy = useContent().working.diagram;
|
||||
const chain = copy.chain;
|
||||
const label = copy.description;
|
||||
|
||||
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 first = ys[0] ?? 96;
|
||||
const last = ys[ys.length - 1] ?? 432;
|
||||
const stops = ys.flatMap((y) => [y, y]);
|
||||
const edge = CHIP.height / 2;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 340 500"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
aria-label={copy.description}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<Caption x={24} y={38} size={12}>
|
||||
@@ -100,60 +64,47 @@ export const ContactDiagram: React.FC = () => {
|
||||
<Caption x={196} y={38} size={12} strong>
|
||||
{copy.withMe}
|
||||
</Caption>
|
||||
<line
|
||||
x1={90}
|
||||
x2={90}
|
||||
y1={first}
|
||||
y2={last}
|
||||
stroke={LINE}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
|
||||
<Track d={`M88 ${first}V${last}`} muted />
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
<Chip
|
||||
key={name}
|
||||
x={90}
|
||||
x={88}
|
||||
y={ys[i] ?? 0}
|
||||
{...CHIP}
|
||||
label={name}
|
||||
size={12}
|
||||
muted={i > 0}
|
||||
size={11}
|
||||
tone={i > 0 ? "muted" : "line"}
|
||||
pins={3}
|
||||
/>
|
||||
))}
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={160}
|
||||
fill={MARK}
|
||||
initial={{ cy: first }}
|
||||
animate={{
|
||||
cy: stops,
|
||||
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
|
||||
}}
|
||||
transition={{
|
||||
duration: 9,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
<Bit
|
||||
xs={stops.map(() => 156)}
|
||||
ys={stops}
|
||||
opacity={fading(stops)}
|
||||
duration={9}
|
||||
pause={0.8}
|
||||
/>
|
||||
<line
|
||||
x1={250}
|
||||
x2={250}
|
||||
y1={first}
|
||||
y2={last}
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
|
||||
<Track d={`M252 ${first + edge}V${last - edge}`} bus />
|
||||
<Chip x={252} y={first} {...CHIP} label={copy.you} size={11} pins={3} />
|
||||
<Chip
|
||||
x={252}
|
||||
y={last}
|
||||
{...CHIP}
|
||||
label={copy.me}
|
||||
size={11}
|
||||
tone="lit"
|
||||
pins={3}
|
||||
/>
|
||||
<Node x={250} y={first} label={copy.you} size={12} />
|
||||
<Node x={250} y={last} label={copy.me} size={12} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={250}
|
||||
fill={MARK}
|
||||
initial={{ cy: first + NODE_H / 2 }}
|
||||
animate={{
|
||||
cy: [first + NODE_H / 2, last - NODE_H / 2, first + NODE_H / 2],
|
||||
}}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
|
||||
<Pad x={252} y={first + edge} />
|
||||
<Pad x={252} y={last - edge} lit />
|
||||
<Bit
|
||||
xs={[252, 252, 252]}
|
||||
ys={[first + edge + 10, last - edge - 10, first + edge + 10]}
|
||||
opacity={[1, 1, 1]}
|
||||
duration={2.4}
|
||||
pause={0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -163,11 +114,12 @@ export const ContactDiagram: React.FC = () => {
|
||||
const first = xs[0] ?? 76;
|
||||
const last = xs[xs.length - 1] ?? 604;
|
||||
const stops = xs.flatMap((x) => [x, x]);
|
||||
const edge = CHIP.width / 2;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 250"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
aria-label={copy.description}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<defs>
|
||||
@@ -182,67 +134,43 @@ export const ContactDiagram: React.FC = () => {
|
||||
</defs>
|
||||
<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}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
x2={last}
|
||||
y1={74}
|
||||
y2={74}
|
||||
stroke={LINE}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
<Track d={`M${first} 82H${last}`} muted />
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
<Chip
|
||||
key={name}
|
||||
x={xs[i] ?? 0}
|
||||
y={74}
|
||||
y={82}
|
||||
{...CHIP}
|
||||
label={name}
|
||||
size={10}
|
||||
muted={i > 0}
|
||||
tone={i > 0 ? "muted" : "line"}
|
||||
/>
|
||||
))}
|
||||
<motion.circle
|
||||
r={5}
|
||||
cy={46}
|
||||
fill={MARK}
|
||||
initial={{ cx: first }}
|
||||
animate={{
|
||||
cx: stops,
|
||||
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
|
||||
}}
|
||||
transition={{
|
||||
duration: 9,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
<Bit
|
||||
xs={stops}
|
||||
ys={stops.map(() => 46)}
|
||||
opacity={fading(stops)}
|
||||
duration={9}
|
||||
pause={0.8}
|
||||
/>
|
||||
|
||||
<Caption x={20} y={160} size={10} strong>
|
||||
{copy.withMeLong}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
x2={last}
|
||||
y1={204}
|
||||
y2={204}
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={first} y={204} label={copy.you} size={10} />
|
||||
<Node x={last} y={204} label={copy.me} size={10} solid />
|
||||
<motion.circle
|
||||
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" }}
|
||||
<Track d={`M${first + edge} 204H${last - edge}`} bus />
|
||||
<Chip x={first} y={204} {...CHIP} label={copy.you} size={10} />
|
||||
<Chip x={last} y={204} {...CHIP} label={copy.me} size={10} tone="lit" />
|
||||
<Pad x={first + edge} y={204} />
|
||||
<Pad x={last - edge} y={204} lit />
|
||||
<Bit
|
||||
xs={[first + edge + 14, last - edge - 14, first + edge + 14]}
|
||||
ys={[204, 204, 204]}
|
||||
opacity={[1, 1, 1]}
|
||||
duration={2.4}
|
||||
pause={0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Bit, Chip, Pad, Track } from "./circuit";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import type { SceneLabels } from "@/src/content/types";
|
||||
import { fill } from "@/src/domain/template";
|
||||
@@ -48,29 +49,6 @@ type Scene = React.FC<{ l: SceneLabels }>;
|
||||
|
||||
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[];
|
||||
@@ -78,28 +56,7 @@ const Packet: React.FC<{
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}> = (props) => <Bit {...props} />;
|
||||
|
||||
const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
|
||||
x,
|
||||
@@ -544,44 +501,45 @@ const Decide: Scene = ({ l }) => (
|
||||
>
|
||||
{l.you}
|
||||
</text>
|
||||
<Pad x={116} y={150} />
|
||||
{[70, 150, 230]
|
||||
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
|
||||
.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}
|
||||
.map((option, i) => {
|
||||
// Straight out, one 45 degree bend, straight in.
|
||||
const bend = Math.abs(option.y - 150);
|
||||
const turn = 150 + bend;
|
||||
return (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M116 150H150L${turn} ${option.y}H250`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="square"
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
<Packet
|
||||
xs={[116, 150, turn, 250]}
|
||||
ys={[150, 150, option.y, option.y]}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<Chip
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -852,21 +810,34 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
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>
|
||||
<Chip
|
||||
x={x}
|
||||
y={y}
|
||||
width={112}
|
||||
height={44}
|
||||
label={label}
|
||||
size="var(--scene-label, 10px)"
|
||||
/>
|
||||
);
|
||||
|
||||
/** Where the tracks between the tools end. */
|
||||
const TOOL_PADS: readonly Pt[] = [
|
||||
[146, 80],
|
||||
[334, 80],
|
||||
[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. */
|
||||
@@ -937,6 +908,7 @@ const Wiring: Scene = ({ l }) => (
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
<ToolPads delay={1.9} />
|
||||
<motion.text
|
||||
x={240}
|
||||
y={268}
|
||||
@@ -950,42 +922,35 @@ const Wiring: Scene = ({ l }) => (
|
||||
);
|
||||
|
||||
/** 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 }) => (
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
<Track key={d} d={d} bus />
|
||||
))}
|
||||
{TOOLS.map((tool, 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
|
||||
x={240}
|
||||
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>
|
||||
);
|
||||
@@ -74,7 +74,7 @@ export const de: Content = {
|
||||
},
|
||||
services: {
|
||||
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?",
|
||||
youGet: "Das bekommen Sie",
|
||||
howItRuns: "So läuft es ab",
|
||||
@@ -98,7 +98,6 @@ export const de: Content = {
|
||||
orientationLabel: "Zur Orientierung",
|
||||
net: "Alle Preise netto.",
|
||||
cta: "Schätzung anfragen",
|
||||
freelance: "Buchung und Preise für Teams",
|
||||
},
|
||||
paths: [
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@ export const en: Content = {
|
||||
},
|
||||
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?",
|
||||
youGet: "What you get",
|
||||
howItRuns: "How it runs",
|
||||
@@ -97,7 +97,6 @@ export const en: Content = {
|
||||
orientationLabel: "For orientation",
|
||||
net: "All prices net.",
|
||||
cta: "Ask for an estimate",
|
||||
freelance: "Booking and rates for teams",
|
||||
},
|
||||
paths: [
|
||||
{
|
||||
|
||||
@@ -197,8 +197,6 @@ export type Content = {
|
||||
readonly orientationLabel: string;
|
||||
readonly net: string;
|
||||
readonly cta: string;
|
||||
/** The pointer to the booking terms on the freelance page. */
|
||||
readonly freelance: string;
|
||||
};
|
||||
readonly paths: readonly ServicePath[];
|
||||
readonly scenes: SceneLabels;
|
||||
|
||||
Reference in New Issue
Block a user