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;