From 90120b60db3e1988cf0e28d08ed380b4d3436ab9 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 5 Oct 2026 10:24:52 +0200 Subject: [PATCH] feat: the homepage leads the visitor through as one story The page was a row of sections to scroll past, and a film in one of them did not change that. It now follows the questions a visitor has, in the order they come up: who is this, can he do what I need, what does it look like, what else, how does it go, has he done it, what is open, how do I start. Each chapter ends by asking the next question as a large link that leads on, and the next chapter answers it. A rail at the edge shows every chapter as a station with a dot travelling along it (on phones: a pill with the current chapter and one tap to the next), and the header names the current question. Where the visitor is, is pure logic in src/domain/chapters.ts. The scroll film section and the bridge sentences are removed. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 +- apps/web/app/[locale]/page.tsx | 37 ++- apps/web/app/globals.css | 17 ++ apps/web/src/components/Header.tsx | 7 +- 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/components/home/Block.tsx | 41 +-- apps/web/src/components/home/ClosingCta.tsx | 1 + .../src/components/home/PortraitSection.tsx | 1 + apps/web/src/components/story/Chapter.tsx | 61 +++++ apps/web/src/components/story/StoryGuide.tsx | 139 +++++++++++ apps/web/src/components/story/useChapter.ts | 74 ++++++ apps/web/src/content/de.ts | 41 +-- apps/web/src/content/en.ts | 41 +-- apps/web/src/content/types.ts | 38 ++- apps/web/src/domain/chapters.spec.ts | 65 +++++ apps/web/src/domain/chapters.ts | 27 ++ 19 files changed, 518 insertions(+), 640 deletions(-) delete mode 100644 apps/web/src/components/film/OrderFilm.tsx delete mode 100644 apps/web/src/components/film/OrderShot.tsx delete mode 100644 apps/web/src/components/film/ScrollFilm.tsx delete mode 100644 apps/web/src/components/film/motion.ts create mode 100644 apps/web/src/components/story/Chapter.tsx create mode 100644 apps/web/src/components/story/StoryGuide.tsx create mode 100644 apps/web/src/components/story/useChapter.ts create mode 100644 apps/web/src/domain/chapters.spec.ts create mode 100644 apps/web/src/domain/chapters.ts diff --git a/CLAUDE.md b/CLAUDE.md index af5125a..a1748be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,12 +63,12 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta along the edge of an element, digits drifting off it; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam` (lit top edge of a dark section), `BitGlow` (light following the pointer on `data-glow` surfaces). 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. +- The homepage is a guided story (`apps/web/src/components/story/`): it follows the questions a visitor has + (`story.chapters` in the content files). Each chapter ends with `NextChapter`, the next question as a link + that leads on; `StoryGuide` is the rail with one station per chapter and a travelling dot (a pill on + phones), and the header names the current question. Sections become chapters through `data-chapter` + (`Block` prop `chapter`). Where the visitor is, is computed in `src/domain/chapters.ts`. A new section + on the homepage needs a chapter and a question, or it breaks the thread. - `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 2f21818..93a09c6 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -1,7 +1,6 @@ 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"; @@ -19,9 +18,11 @@ import { } from "@/src/components/illustrations/WebPresentation"; import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase"; import { Reveal } from "@/src/components/Reveal"; +import { NextChapter } from "@/src/components/story/Chapter"; +import { StoryGuide } from "@/src/components/story/StoryGuide"; import { SwipeRow } from "@/src/components/SwipeRow"; import { contentFor } from "@/src/content"; -import type { SplitTitle } from "@/src/content/types"; +import type { ChapterId, SplitTitle } from "@/src/content/types"; import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale"; import { languageAlternates, pathFor } from "@/src/domain/routes"; @@ -69,11 +70,28 @@ export default async function LandingPage({ params }: Props) { faq, } = content; + /** What makes a section a chapter, and the hand-over to the one after it. */ + const chapter = (id: ChapterId) => { + const at = story.chapters.findIndex((item) => item.id === id); + const own = story.chapters[at]; + const after = story.chapters[at + 1]; + return { + chapter: own && { id: own.id }, + next: after && { + id: after.id, + label: story.next, + question: after.question, + }, + }; + }; + const afterHello = chapter("hello").next; + return (
+ + {afterHello && }
} > @@ -115,12 +134,10 @@ export default async function LandingPage({ params }: Props) { - - } > @@ -153,7 +170,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -226,7 +243,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -277,7 +294,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -292,7 +309,7 @@ export default async function LandingPage({ params }: Props) { } > diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2c9e0b7..e4dae52 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1339,6 +1339,23 @@ pre:has(code[class*="language-"]) { } } +/* The story guide: the dot that travels along the chapter rail, and the header line. */ +.story-dot { + box-shadow: + 0 0 0 3px rgba(253, 230, 138, 0.35), + 0 0 14px 3px rgba(251, 191, 36, 0.8); + transition: top 200ms linear; +} +.story-now { + animation: story-now-in 400ms cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes story-now-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + /* * Native feel: the page should behave like a well-made app on every input type. */ diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index 25bf4a5..6013bc3 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -9,6 +9,7 @@ import { DecodeText } from "./bits/DecodeText"; import { ScrollBits } from "./bits/ScrollBits"; import { useContent, useLocale } from "./ContentProvider"; import { LanguageSwitch } from "./LanguageSwitch"; +import { ChapterNow } from "./story/StoryGuide"; /** Header: the logo, the link to the freelance page, the language switch and one action. */ export const Header: React.FC = () => { @@ -61,7 +62,11 @@ export const Header: React.FC = () => { - + + } + />
= ({ 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 deleted file mode 100644 index d170833..0000000 --- a/apps/web/src/components/film/OrderShot.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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 deleted file mode 100644 index 624cc62..0000000 --- a/apps/web/src/components/film/ScrollFilm.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"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 deleted file mode 100644 index 59cd774..0000000 --- a/apps/web/src/components/film/motion.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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/components/home/Block.tsx b/apps/web/src/components/home/Block.tsx index 47a37a0..69f635f 100644 --- a/apps/web/src/components/home/Block.tsx +++ b/apps/web/src/components/home/Block.tsx @@ -4,14 +4,17 @@ import { BitSeam } from "@/src/components/bits/BitSeam"; import { BitWipe } from "@/src/components/bits/BitWipe"; import { DecodeText } from "@/src/components/bits/DecodeText"; import { Reveal } from "@/src/components/Reveal"; +import { ChapterThread, NextChapter } from "@/src/components/story/Chapter"; interface BlockProps { /** Two-digit section index, shown with its binary form. */ index: string; label: string; title: React.ReactNode; - /** One sentence that leads over from the section before (the thread of the page). */ - bridge?: string; + /** Makes the section a chapter of the story, reachable under this id. */ + chapter?: { id: string }; + /** The chapter this one hands over to at its end. */ + next?: { id: string; label: string; question: string }; id?: string; /** `dark` turns the section into a dark stage. */ tone?: "light" | "dark"; @@ -26,7 +29,8 @@ export const Block: React.FC = ({ index, label, title, - bridge, + chapter, + next, id, tone = "light", children, @@ -34,7 +38,8 @@ export const Block: React.FC = ({ const dark = tone === "dark"; return (
= ({ )} {dark && }
- {bridge && ( - -
-
-
+ {chapter && ( +
+ +
)}
@@ -99,6 +92,14 @@ export const Block: React.FC = ({
{children}
+ {next && ( + + )}
); diff --git a/apps/web/src/components/home/ClosingCta.tsx b/apps/web/src/components/home/ClosingCta.tsx index ae68ed4..8c23fae 100644 --- a/apps/web/src/components/home/ClosingCta.tsx +++ b/apps/web/src/components/home/ClosingCta.tsx @@ -13,6 +13,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({ }) => (
diff --git a/apps/web/src/components/home/PortraitSection.tsx b/apps/web/src/components/home/PortraitSection.tsx index c8651d5..b1c1bd2 100644 --- a/apps/web/src/components/home/PortraitSection.tsx +++ b/apps/web/src/components/home/PortraitSection.tsx @@ -22,6 +22,7 @@ export const PortraitSection: React.FC<{ }> = ({ id, index, label, title, portraitAlt, children }) => (
{/* Portrait: right side on wide screens, top on small ones. */} diff --git a/apps/web/src/components/story/Chapter.tsx b/apps/web/src/components/story/Chapter.tsx new file mode 100644 index 0000000..10c677b --- /dev/null +++ b/apps/web/src/components/story/Chapter.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { Reveal } from "@/src/components/Reveal"; + +/** + * The opening of a chapter: the line coming down from the question the chapter + * before ended on. The question itself is not repeated here; it stands directly + * above (see `NextChapter`) and in the header. + */ +export const ChapterThread: React.FC<{ dark?: boolean }> = ({ + dark = false, +}) => ( +