From f0c25b802b70b06311a9c168e35c160d3d842494 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 5 Oct 2026 11:37:26 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 5 + apps/web/app/[locale]/freelance/page.tsx | 16 +- .../components/Effects/AbstractCircuit.tsx | 30 ++- .../web/src/components/home/HeroStatement.tsx | 2 +- apps/web/src/components/home/PathPicker.tsx | 24 +- .../illustrations/ContactDiagram.tsx | 216 ++++++------------ .../components/illustrations/PathScenes.tsx | 213 ++++++++--------- .../src/components/illustrations/circuit.tsx | 210 +++++++++++++++++ apps/web/src/content/de.ts | 3 +- apps/web/src/content/en.ts | 3 +- apps/web/src/content/types.ts | 2 - 11 files changed, 430 insertions(+), 294 deletions(-) create mode 100644 apps/web/src/components/illustrations/circuit.tsx 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;