From f50bb553e8ad5a5c045ae313a0bfc1171ab9d075 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 5 Oct 2026 10:09:50 +0200 Subject: [PATCH] feat: the automation chapter opens with a scroll film The homepage read as sections to scroll past. The automation chapter now opens the way the social media films do: one continuous shot on a dark stage that stays in place while the visitor scrolls. Scrolling moves the camera (close on a dot hopping between two squares, pull back to a whole office carrying things from tool to tool, freeze, lines take over) and changes the statement; a second clock keeps the dots moving when the visitor stops. The statements are the approved ones from the film. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + apps/web/app/[locale]/page.tsx | 3 + apps/web/src/components/film/OrderFilm.tsx | 48 ++++ apps/web/src/components/film/OrderShot.tsx | 235 ++++++++++++++++++++ apps/web/src/components/film/ScrollFilm.tsx | 218 ++++++++++++++++++ apps/web/src/components/film/motion.ts | 55 +++++ apps/web/src/content/de.ts | 11 + apps/web/src/content/en.ts | 11 + apps/web/src/content/types.ts | 5 + 9 files changed, 592 insertions(+) create mode 100644 apps/web/src/components/film/OrderFilm.tsx create mode 100644 apps/web/src/components/film/OrderShot.tsx create mode 100644 apps/web/src/components/film/ScrollFilm.tsx create mode 100644 apps/web/src/components/film/motion.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3d65ad4..48c4c1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,12 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m `BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit edge that sheds digits; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam`, `BitGlow`. Reuse them instead of inventing new effects. +- Scroll films live in `apps/web/src/components/film/`: `ScrollFilm` is a dark stage that stays in place + while the visitor scrolls; scrolling is the timeline (camera, statements), a second clock keeps things + alive when the visitor stops. They are the website form of the films in `apps/video` (same pattern: close + on something abstract, pull back, resolve) and use their approved statements. `OrderFilm` opens the + automation chapter. Coordinates rendered on the first frame must be rounded (see `motion.ts`), or server + and browser disagree in the last digit. - `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]/page.tsx b/apps/web/app/[locale]/page.tsx index 6f4b218..2f21818 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { BitAura } from "@/src/components/bits/BitAura"; +import { OrderFilm } from "@/src/components/film/OrderFilm"; import { Block } from "@/src/components/home/Block"; import { ClosingCta } from "@/src/components/home/ClosingCta"; import { Faq } from "@/src/components/home/Faq"; @@ -114,6 +115,8 @@ export default async function LandingPage({ params }: Props) { + + = ({ clock }) => { + const walk = React.useRef(0); + const seen = React.useRef(clock.live); + if (clock.live !== seen.current) { + seen.current = clock.live; + walk.current += + clock.step * + (1 - progress(clock.shot, ORDER_SHOT.freeze, ORDER_SHOT.freeze + 30)); + } + return ; +}; + +/** The opening of the automation chapter: from ping-pong to tools that talk to each other. */ +export const OrderFilm: React.FC = () => { + const { film } = useContent().automation; + const captions = SLOTS.map((slot, index) => ({ + ...slot, + lines: film.statements[index] ?? [], + })); + return ( + + {(clock) => } + + ); +}; diff --git a/apps/web/src/components/film/OrderShot.tsx b/apps/web/src/components/film/OrderShot.tsx new file mode 100644 index 0000000..d170833 --- /dev/null +++ b/apps/web/src/components/film/OrderShot.tsx @@ -0,0 +1,235 @@ +import * as React from "react"; +import type { FilmClock } from "./ScrollFilm"; +import { hopAt, inOut, mix, out, progress, type Point } from "./motion"; + +/** + * The automation film as a scroll shot (twin of `OrderFilm` in apps/video). + * Close up, a dot hops between two squares: ping-pong. The camera pulls back: + * it is one of many, a whole office carrying things from tool to tool. + * Everything stops, the dots sink onto a line, and the lines carry the work. + */ +export const ORDER_SHOT = { + /** The camera starts to pull back. */ + reveal: 75, + /** The whole picture is in view. */ + wide: 195, + /** Everything slows to a halt. */ + freeze: 310, + sink: 345, + lines: 365, + flow: 440, + end: 560, +} as const; + +const T = ORDER_SHOT; +const INK = "#f8fafc"; +const PAPER = "#0f172a"; +const MARK = "#fde68a"; +const LIGHT = "129, 140, 248"; + +const COLS = 4; +const HERO_ROW = 2; +const PITCH = { x: 220, y: 190 } as const; +const TILE = 96; +const ZOOM_IN = 2.6; +const ZOOM_OUT = 0.8; +/** Height of a hop as a share of its length. */ +const ARC = 0.22; +const CLOSE_HOP_FRAMES = 30; +const PULSE_EVERY = 80; +const PULSE_FRAMES = 55; + +const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x; +const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y; +const stop = (row: number, col: number): Point => ({ + x: colX(col), + y: rowY(row), +}); + +/** Each row has someone carrying things round its four tools, in their own order and pace. */ +const WALKERS = [ + { route: [0, 1, 2, 3], hopFrames: 27, offset: 7 }, + { route: [2, 0, 3, 1], hopFrames: 32, offset: 40 }, + { route: [1, 2, 3, 0], hopFrames: 29, offset: 0 }, + { route: [3, 2, 1, 0], hopFrames: 25, offset: 19 }, + { route: [1, 3, 0, 2], hopFrames: 30, offset: 55 }, +] as const; + +const Dot: React.FC<{ + at: (back: number) => Point; + radius: number; + zoom: number; + opacity: number; +}> = ({ at, radius, zoom, opacity }) => { + if (opacity <= 0) return null; + const point = at(0); + return ( + + {[1, 2, 3, 4, 5].map((k) => { + const ghost = at(k * 2.2); + return ( + + ); + })} + + + ); +}; + +/** The picture at one moment. `walk` is the walkers' own time: it stops at the freeze. */ +export const OrderShot: React.FC<{ clock: FilmClock; walk: number }> = ({ + clock, + walk, +}) => { + const { shot, live } = clock; + const back = inOut(progress(shot, T.reveal, T.wide)); + const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back; + /** Everything outside the opening picture fades in as the camera pulls back. */ + const others = progress(back, 0.05, 0.5); + const hair = 2.2 / zoom; + const sink = inOut(progress(shot, T.sink, T.sink + 30)); + const radius = 12 / Math.sqrt(zoom); + + return ( + + {WALKERS.map((walker, row) => { + const hero = row === HERO_ROW; + const y = rowY(row); + const away = Math.abs(row - HERO_ROW); + + // The line that replaces the walking. + const lineStart = T.lines + away * 12; + const drawn = out(progress(shot, lineStart, lineStart + 45)); + const lineEnd = mix(colX(0), colX(COLS - 1), drawn); + + // Work travelling along the line by itself, in its own time. + const flowing = progress( + shot, + T.flow + away * 10, + T.flow + away * 10 + 10, + ); + const pulseT = ((live + away * 23) % PULSE_EVERY) / PULSE_FRAMES; + const pulseX = + flowing > 0 && pulseT <= 1 + ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) + : null; + + const shown = + (hero ? 1 : others) * (1 - progress(shot, lineStart, lineStart + 18)); + const settle = (point: Point): Point => ({ + x: point.x, + y: mix(point.y, y, sink), + }); + const onRoute = (ago: number): Point => + settle( + hopAt( + walk - ago + walker.offset, + walker.route.map((col) => stop(row, col)), + walker.hopFrames, + ARC, + ), + ); + // While the camera is close, the one in the middle plays ping-pong between two squares. + const pingPong = (ago: number): Point => + settle( + hopAt( + walk - ago, + [stop(row, 1), stop(row, 2)], + CLOSE_HOP_FRAMES, + ARC * 1.4, + ), + ); + const walkerNow = onRoute(0); + + return ( + + {drawn > 0 && ( + + )} + {Array.from({ length: COLS }, (_unused, col) => { + const x = colX(col); + const opening = hero && (col === 1 || col === 2); + const touched = + shown > 0.5 && + (!hero || others > 0.5) && + Math.hypot(walkerNow.x - x, walkerNow.y - y) < TILE * 0.42; + const lit = + pulseX === null + ? 0 + : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * flowing; + return ( + + + + + ); + })} + {pulseX !== null && ( + + )} + {hero && ( + + )} + + + ); + })} + + ); +}; diff --git a/apps/web/src/components/film/ScrollFilm.tsx b/apps/web/src/components/film/ScrollFilm.tsx new file mode 100644 index 0000000..624cc62 --- /dev/null +++ b/apps/web/src/components/film/ScrollFilm.tsx @@ -0,0 +1,218 @@ +"use client"; + +import * as React from "react"; +import { progress } from "./motion"; + +/** One statement of a film: its lines, and while which frames of the shot it stands. */ +export type FilmCaption = { + readonly lines: readonly string[]; + readonly from: number; + readonly to: number; +}; + +/** Two clocks: where the shot is (set by scrolling) and time that simply passes. */ +export type FilmClock = { + /** Frame of the shot. Scrolling moves it; it stands still when the visitor does. */ + readonly shot: number; + /** Frames at 30 per second since the film came into view. Things that live keep moving on it. */ + readonly live: number; + /** Frames since the last tick, for things that keep their own pace. */ + readonly step: number; +}; + +interface ScrollFilmProps { + /** The last frame of the shot, reached when the film has been scrolled through. */ + frames: number; + captions: readonly FilmCaption[]; + /** What the film shows, for screen readers. */ + description: string; + /** The frame shown without motion: when reduced motion is requested. */ + stillFrame: number; + /** The picture, as SVG content in a 1000 x 700 stage with (0, 0) in the centre. */ + children: (clock: FilmClock) => React.ReactNode; +} + +const STAGE = { width: 1000, height: 700 } as const; +const FPS = 30; +const MAX_STEP_S = 0.1; +/** How quickly the shot follows the scroll position: lower is smoother. */ +const FOLLOW = 0.14; +const IN_FRAMES = 14; +const OUT_FRAMES = 10; +const EDGE_FADE = + "linear-gradient(to bottom, transparent, #000 10%, #000 90%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)"; + +/** + * ScrollFilm: one continuous shot on a dark stage that stays in place while the + * visitor scrolls through it. Scrolling is the timeline: it moves the camera and + * changes the statement. It is the website form of the social media films + * (apps/video, `FilmStage`): one picture, one statement at a time. + */ +export const ScrollFilm: React.FC = ({ + frames, + captions, + description, + stillFrame, + children, +}) => { + const trackRef = React.useRef(null); + const [clock, setClock] = React.useState({ + shot: 0, + live: 0, + step: 0, + }); + const [still, setStill] = React.useState(false); + + React.useEffect(() => { + const track = trackRef.current; + if (!track) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + setStill(true); + setClock({ shot: stillFrame, live: 0, step: 0 }); + return; + } + + let frame = 0; + let last = 0; + let shot = 0; + let live = 0; + let visible = false; + + const target = () => { + const rect = track.getBoundingClientRect(); + const distance = rect.height - window.innerHeight; + return distance <= 0 ? 0 : progress(-rect.top, 0, distance) * frames; + }; + + const tick = (now: number) => { + frame = window.requestAnimationFrame(tick); + const seconds = Math.min(MAX_STEP_S, (now - last) / 1000); + last = now; + const step = seconds * FPS; + live += step; + shot += (target() - shot) * FOLLOW; + setClock({ shot, live, step }); + }; + + const observer = new IntersectionObserver(([entry]) => { + const next = entry?.isIntersecting ?? false; + if (next === visible) return; + visible = next; + if (visible) { + last = performance.now(); + frame = window.requestAnimationFrame(tick); + } else { + window.cancelAnimationFrame(frame); + } + }); + observer.observe(track); + + return () => { + observer.disconnect(); + window.cancelAnimationFrame(frame); + }; + }, [frames, stillFrame]); + + const { shot } = clock; + + return ( +
+
+ + ); +}; diff --git a/apps/web/src/components/film/motion.ts b/apps/web/src/components/film/motion.ts new file mode 100644 index 0000000..59cd774 --- /dev/null +++ b/apps/web/src/components/film/motion.ts @@ -0,0 +1,55 @@ +/** + * Motion helpers for the scroll films. All pure functions of a frame or a time, + * the same ones the social media films use, so both move alike. + */ + +export type Point = { readonly x: number; readonly y: number }; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +/** 0 before `from`, 1 after `to`, linear in between. */ +export const progress = (frame: number, from: number, to: number): number => + clamp01((frame - from) / (to - from)); + +export const mix = (from: number, to: number, t: number): number => + from + (to - from) * t; + +export const inOut = (t: number): number => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; + +export const out = (t: number): number => 1 - (1 - t) ** 3; + +const easeInOut = (t: number): number => + t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; + +const round = (value: number): number => Math.round(value * 100) / 100; + +const wrap = (index: number, length: number): number => + ((index % length) + length) % length; + +/** + * Where something is that hops from stop to stop in an arc, round and round. + * It rests on `stops[0]` at time 0 and reaches the next stop every + * `framesPerHop`. The arc rises by `arcRatio` times the distance covered. + */ +export const hopAt = ( + time: number, + stops: readonly Point[], + framesPerHop: number, + arcRatio: number, +): Point => { + const hop = Math.floor(time / framesPerHop); + const from = stops[wrap(hop, stops.length)]; + const to = stops[wrap(hop + 1, stops.length)]; + if (!from || !to) return { x: 0, y: 0 }; + const t = easeInOut(time / framesPerHop - hop); + const distance = Math.hypot(to.x - from.x, to.y - from.y); + // Rounded: sine and square root differ in the last digit between the server + // and the browser, and the first frame is rendered by both. + return { + x: round(mix(from.x, to.x, t)), + y: round( + mix(from.y, to.y, t) - Math.sin(Math.PI * t) * distance * arcRatio, + ), + }; +}; diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts index 3bb63d2..c919ec7 100644 --- a/apps/web/src/content/de.ts +++ b/apps/web/src/content/de.ts @@ -277,6 +277,17 @@ export const de: Content = { }, }, automation: { + film: { + description: + "Ein Punkt springt zwischen zwei Quadraten hin und her. Die Kamera fährt zurück: Es sind viele Reihen, in denen jemand Dinge von Programm zu Programm trägt. Dann verbinden Linien die Programme, und die Arbeit läuft von selbst.", + statements: [ + ["Sieht aus wie", "Pingpong."], + ["Ist aber", "Ihr Büro."], + ["Jede Bestellung.", "Von Hand."], + ["Oder Ihre Tools", "reden miteinander."], + ["Und Ihr Team", "hat Zeit."], + ], + }, label: "Prozessautomatisierung", title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." }, intro: diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts index 824e95a..4f10a86 100644 --- a/apps/web/src/content/en.ts +++ b/apps/web/src/content/en.ts @@ -273,6 +273,17 @@ export const en: Content = { }, }, automation: { + film: { + description: + "A dot hops back and forth between two squares. The camera pulls back: there are many rows in which someone carries things from tool to tool. Then lines connect the tools and the work runs by itself.", + statements: [ + ["Looks like", "ping-pong."], + ["It's your", "office."], + ["Every order.", "By hand."], + ["Or your tools", "talk to each other."], + ["And your team", "has time."], + ], + }, label: "Process automation", title: { lead: "Let the systems", rest: "do the copying." }, intro: diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts index 3dddb37..54170ba 100644 --- a/apps/web/src/content/types.ts +++ b/apps/web/src/content/types.ts @@ -194,6 +194,11 @@ export type Content = { readonly scenes: SceneLabels; }; readonly automation: { + /** The film that opens the chapter: what it shows, and its five statements in order. */ + readonly film: { + readonly description: string; + readonly statements: readonly (readonly string[])[]; + }; readonly label: string; readonly title: SplitTitle; readonly intro: string;