diff --git a/CLAUDE.md b/CLAUDE.md
index a7025bb..66c1c4a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`).
diff --git a/apps/web/app/[locale]/freelance/page.tsx b/apps/web/app/[locale]/freelance/page.tsx
index 85cb3eb..991cadb 100644
--- a/apps/web/app/[locale]/freelance/page.tsx
+++ b/apps/web/app/[locale]/freelance/page.tsx
@@ -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 (
@@ -137,8 +140,19 @@ export default async function FreelancePage({ params }: Props) {
+ {teamPath && (
+
+
+ {teamPath.intro}
+
+
+
+
+
+ )}
+
diff --git a/apps/web/src/components/Effects/AbstractCircuit.tsx b/apps/web/src/components/Effects/AbstractCircuit.tsx
index d4d00aa..217b37f 100644
--- a/apps/web/src/components/Effects/AbstractCircuit.tsx
+++ b/apps/web/src/components/Effects/AbstractCircuit.tsx
@@ -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]);
diff --git a/apps/web/src/components/home/HeroStatement.tsx b/apps/web/src/components/home/HeroStatement.tsx
index d2c29fe..80a386a 100644
--- a/apps/web/src/components/home/HeroStatement.tsx
+++ b/apps/web/src/components/home/HeroStatement.tsx
@@ -8,7 +8,7 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero,
}) => (
-
+
diff --git a/apps/web/src/components/home/PathPicker.tsx b/apps/web/src/components/home/PathPicker.tsx
index 1bc54b5..015b15f 100644
--- a/apps/web/src/components/home/PathPicker.tsx
+++ b/apps/web/src/components/home/PathPicker.tsx
@@ -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 = () => {
{paths.map((path, index) => {
const selected = path.id === activeId;
@@ -285,14 +287,6 @@ export const PathPicker: React.FC = () => {
{estimate.cta}
- {path.id === "developer" && (
-
- {estimate.freelance} →
-
- )}
diff --git a/apps/web/src/components/illustrations/ContactDiagram.tsx b/apps/web/src/components/illustrations/ContactDiagram.tsx
index fb843eb..ce69dd0 100644
--- a/apps/web/src/components/illustrations/ContactDiagram.tsx
+++ b/apps/web/src/components/illustrations/ContactDiagram.tsx
@@ -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 }) => (
-
-
-
- {label}
-
-
-);
+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}
);
+/** 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 (
@@ -100,60 +64,47 @@ export const ContactDiagram: React.FC = () => {
{copy.withMe}
-
+
+
{chain.map((name, i) => (
- 0}
+ size={11}
+ tone={i > 0 ? "muted" : "line"}
+ pins={3}
/>
))}
- 0.95 - (i / stops.length) * 0.6),
- }}
- transition={{
- duration: 9,
- repeat: Infinity,
- repeatDelay: 0.8,
- ease: "easeInOut",
- }}
+ 156)}
+ ys={stops}
+ opacity={fading(stops)}
+ duration={9}
+ pause={0.8}
/>
-
+
+
-
-
-
+
+
);
@@ -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 (
@@ -182,67 +134,43 @@ export const ContactDiagram: React.FC = () => {
-
+
{copy.oftenLong}
-
+
{chain.map((name, i) => (
- 0}
+ tone={i > 0 ? "muted" : "line"}
/>
))}
- 0.95 - (i / stops.length) * 0.6),
- }}
- transition={{
- duration: 9,
- repeat: Infinity,
- repeatDelay: 0.8,
- ease: "easeInOut",
- }}
+ 46)}
+ opacity={fading(stops)}
+ duration={9}
+ pause={0.8}
/>
{copy.withMeLong}
-
-
-
-
+
+
+
+
+
);
diff --git a/apps/web/src/components/illustrations/PathScenes.tsx b/apps/web/src/components/illustrations/PathScenes.tsx
index 8b0934f..5655af5 100644
--- a/apps/web/src/components/illustrations/PathScenes.tsx
+++ b/apps/web/src/components/illustrations/PathScenes.tsx
@@ -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 }) => (
- (i === 0 || i === xs.length - 1 ? 0 : 1)),
- }}
- transition={{
- duration,
- delay,
- repeat: Infinity,
- repeatDelay: pause,
- ease: "easeInOut",
- }}
- />
-);
+}> = (props) => ;
const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
x,
@@ -544,44 +501,45 @@ const Decide: Scene = ({ l }) => (
>
{l.you}
+
{[70, 150, 230]
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
- .map((option, i) => (
-
-
-
-
- {
+ // Straight out, one 45 degree bend, straight in.
+ const bend = Math.abs(option.y - 150);
+ const turn = 150 + bend;
+ return (
+
+
-
- {option.label}
-
-
-
- ))}
+
+
+
+
+
+
+ );
+ })}
);
@@ -852,21 +810,34 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
y,
label,
}) => (
-
-
-
- {label}
-
-
+
+);
+
+/** 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 }) => (
+
+ {TOOL_PADS.map(([x, y]) => (
+
+ ))}
+
);
/** 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) => (
))}
+
(
);
/** 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 }) => (
{WIRES.map((d) => (
-
- ))}
- {[
- { 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) => (
-
+
))}
{TOOLS.map((tool, i) => (
))}
+
+ {RUNS.map(([fx, fy, tx, ty], i) => (
+
+ ))}
= ({ 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 (
+
+ {Array.from({ length: pins }, (_, i) => {
+ const px = left + ((i + 1) * width) / (pins + 1);
+ return (
+
+ );
+ })}
+
+ {/* The notch that marks pin one. */}
+
+
+ {label}
+
+
+ );
+};
+
+/** 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 }) => (
+
+);
+
+/**
+ * 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,
+}) => (
+ (i === 0 || i === xs.length - 1 ? 0 : 1)),
+ }}
+ transition={{
+ duration,
+ delay,
+ repeat: Infinity,
+ repeatDelay: pause,
+ ease: "easeInOut",
+ }}
+ >
+
+
+ {value}
+
+
+);
+
+/** 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 }) => (
+
+ {bus && (
+
+ )}
+
+
+);
diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts
index 66d6e19..bcfa27a 100644
--- a/apps/web/src/content/de.ts
+++ b/apps/web/src/content/de.ts
@@ -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: [
{
diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts
index 4d6991c..2bb1e30 100644
--- a/apps/web/src/content/en.ts
+++ b/apps/web/src/content/en.ts
@@ -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: [
{
diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts
index ae18226..5b2f83b 100644
--- a/apps/web/src/content/types.ts
+++ b/apps/web/src/content/types.ts
@@ -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;