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 (
-
-
-
-
-
-
-
- {/* One statement at a time, in the type of the headlines. */}
-
- );
-};
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 && }
);
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,
+}) => (
+
+);
+
+/**
+ * The end of a chapter: it hands over to the next one by naming the question
+ * that comes up now, as a link that leads there.
+ */
+export const NextChapter: React.FC<{
+ label: string;
+ id: string;
+ question: string;
+ dark?: boolean;
+}> = ({ label, id, question, dark = false }) => (
+
+
+
+
+ {label}
+
+
+ {question}
+
+
+
+ ↓
+
+
+
+);
diff --git a/apps/web/src/components/story/StoryGuide.tsx b/apps/web/src/components/story/StoryGuide.tsx
new file mode 100644
index 0000000..b86486a
--- /dev/null
+++ b/apps/web/src/components/story/StoryGuide.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import * as React from "react";
+import { useContent } from "@/src/components/ContentProvider";
+import { useChapter } from "./useChapter";
+
+const RAIL_HEIGHT = 264;
+
+const goTo = (id: string) => {
+ document
+ .querySelector(`[data-chapter="${id}"]`)
+ ?.scrollIntoView({ behavior: "smooth", block: "start" });
+};
+
+/**
+ * StoryGuide: the visitor's place in the story, always in view. On wide screens
+ * a rail with every chapter as a station and a dot that travels along it; on
+ * phones a pill that names the chapter and leads on to the next one.
+ */
+export const StoryGuide: React.FC = () => {
+ const { story } = useContent();
+ const { index, progress, ids } = useChapter();
+ const chapters = story.chapters.filter((chapter) => ids.includes(chapter.id));
+ if (chapters.length === 0) return null;
+
+ const shown = index >= 0;
+ const current = chapters[index];
+ const next = chapters[index + 1];
+ const last = chapters.length - 1;
+ // The dot rests on a station while its chapter begins and reaches the next one as it ends.
+ const along = Math.min(1, (progress * chapters.length) / last);
+
+ return (
+ <>
+
+
+ {/* Phones: where you are, and one tap to the next chapter. */}
+
+ >
+ );
+};
+
+/** The current chapter and its question, for the middle of the header. */
+export const ChapterNow: React.FC<{ fallback: React.ReactNode }> = ({
+ fallback,
+}) => {
+ const { story } = useContent();
+ const { index, ids } = useChapter();
+ const chapters = story.chapters.filter((chapter) => ids.includes(chapter.id));
+ const current = chapters[index];
+ if (!current) return <>{fallback}>;
+ return (
+
+
+ {String(index + 1).padStart(2, "0")}/
+ {String(chapters.length).padStart(2, "0")}
+
+
+ {current.question}
+
+ );
+};
diff --git a/apps/web/src/components/story/useChapter.ts b/apps/web/src/components/story/useChapter.ts
new file mode 100644
index 0000000..5560114
--- /dev/null
+++ b/apps/web/src/components/story/useChapter.ts
@@ -0,0 +1,74 @@
+"use client";
+
+import * as React from "react";
+import { chapterAt, storyProgress } from "@/src/domain/chapters";
+
+/** Sections that are chapters of the story carry this attribute with their id. */
+export const CHAPTER_ATTRIBUTE = "data-chapter";
+
+/** The line the visitor reads at, as a share of the window height. */
+const READING_LINE = 0.4;
+
+export type StoryPosition = {
+ /** Index of the current chapter in page order, -1 above the first. */
+ readonly index: number;
+ /** 0 to 1 through the story; every chapter owns the same share. */
+ readonly progress: number;
+ /** Ids of the chapters found on the page, in order. */
+ readonly ids: readonly string[];
+};
+
+const NOWHERE: StoryPosition = { index: -1, progress: 0, ids: [] };
+
+/** Where the visitor is in the story. On a page without chapters, `ids` is empty. */
+export const useChapter = (): StoryPosition => {
+ const [position, setPosition] = React.useState(NOWHERE);
+
+ React.useEffect(() => {
+ let frame = 0;
+
+ const measure = () => {
+ frame = 0;
+ const sections = Array.from(
+ document.querySelectorAll(`[${CHAPTER_ATTRIBUTE}]`),
+ );
+ const tops = sections.map(
+ (section) => section.getBoundingClientRect().top + window.scrollY,
+ );
+ const last = sections[sections.length - 1];
+ const end = last
+ ? last.getBoundingClientRect().bottom + window.scrollY
+ : 0;
+ const y = window.scrollY + window.innerHeight * READING_LINE;
+ const next: StoryPosition = {
+ index: chapterAt(tops, y),
+ progress: storyProgress(tops, end, y),
+ ids: sections.map(
+ (section) => section.getAttribute(CHAPTER_ATTRIBUTE) ?? "",
+ ),
+ };
+ setPosition((current) =>
+ current.index === next.index &&
+ Math.abs(current.progress - next.progress) < 0.002 &&
+ current.ids.length === next.ids.length
+ ? current
+ : next,
+ );
+ };
+
+ const schedule = () => {
+ if (frame === 0) frame = window.requestAnimationFrame(measure);
+ };
+
+ measure();
+ window.addEventListener("scroll", schedule, { passive: true });
+ window.addEventListener("resize", schedule);
+ return () => {
+ window.removeEventListener("scroll", schedule);
+ window.removeEventListener("resize", schedule);
+ if (frame !== 0) window.cancelAnimationFrame(frame);
+ };
+ }, []);
+
+ return position;
+};
diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts
index c919ec7..1912e3d 100644
--- a/apps/web/src/content/de.ts
+++ b/apps/web/src/content/de.ts
@@ -277,17 +277,6 @@ 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:
@@ -495,12 +484,30 @@ export const de: Content = {
cta: "Nach einer Präsentation fragen",
},
story: {
- services: "So viel zu mir. Jetzt zu Ihnen.",
- automation: "Einen dieser Fälle zeige ich genauer.",
- presentations: "Und manchmal ist das Richtige gar keine App.",
- working: "Egal, was Sie brauchen: So läuft die Zusammenarbeit.",
- work: "Und so sieht das Ergebnis aus.",
- faq: "Bleiben die Fragen, die fast immer kommen.",
+ next: "Als Nächstes fragen Sie sich",
+ railLabel: "Kapitel der Seite",
+ chapters: [
+ { id: "hello", short: "Wer", question: "Wer ist das überhaupt?" },
+ { id: "services", short: "Was", question: "Kann er, was ich brauche?" },
+ {
+ id: "automation",
+ short: "Beispiel",
+ question: "Wie sieht das konkret aus?",
+ },
+ {
+ id: "presentations",
+ short: "Mehr",
+ question: "Und was geht sonst noch?",
+ },
+ {
+ id: "working",
+ short: "Ablauf",
+ question: "Wie läuft die Zusammenarbeit?",
+ },
+ { id: "work", short: "Ergebnis", question: "Hat er das schon gemacht?" },
+ { id: "faq", short: "Fragen", question: "Was ist noch offen?" },
+ { id: "contact", short: "Start", question: "Wie fange ich an?" },
+ ],
},
freelance: {
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts
index 4f10a86..d06064d 100644
--- a/apps/web/src/content/en.ts
+++ b/apps/web/src/content/en.ts
@@ -273,17 +273,6 @@ 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:
@@ -483,12 +472,30 @@ export const en: Content = {
cta: "Ask about a presentation",
},
story: {
- services: "That is me. Now to you.",
- automation: "One of these cases, shown in more detail.",
- presentations: "And sometimes the right thing is not an app at all.",
- working: "Whatever you need: this is how working together goes.",
- work: "And this is what the result looks like.",
- faq: "That leaves the questions that almost always come up.",
+ next: "Next, you are wondering",
+ railLabel: "Chapters of this page",
+ chapters: [
+ { id: "hello", short: "Who", question: "Who is this, anyway?" },
+ { id: "services", short: "What", question: "Can he do what I need?" },
+ {
+ id: "automation",
+ short: "Example",
+ question: "What does that look like in practice?",
+ },
+ {
+ id: "presentations",
+ short: "More",
+ question: "And what else is possible?",
+ },
+ {
+ id: "working",
+ short: "Process",
+ question: "How does working together go?",
+ },
+ { id: "work", short: "Results", question: "Has he done this before?" },
+ { id: "faq", short: "Questions", question: "What is still open?" },
+ { id: "contact", short: "Start", question: "How do I get started?" },
+ ],
},
freelance: {
metaTitle: "Freelance web developer for your team | Marc Mintel",
diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts
index 54170ba..ce23d0a 100644
--- a/apps/web/src/content/types.ts
+++ b/apps/web/src/content/types.ts
@@ -120,6 +120,24 @@ export type WorkCopy = {
readonly tech: readonly string[];
};
+export type ChapterId =
+ | "hello"
+ | "services"
+ | "automation"
+ | "presentations"
+ | "working"
+ | "work"
+ | "faq"
+ | "contact";
+
+export type StoryChapter = {
+ readonly id: ChapterId;
+ /** One word for the rail. */
+ readonly short: string;
+ /** The question of the visitor that this chapter answers. */
+ readonly question: string;
+};
+
export type FaqItem = {
readonly question: string;
readonly answer: string;
@@ -194,11 +212,6 @@ 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;
@@ -236,16 +249,15 @@ export type Content = {
readonly cta: string;
};
/**
- * The thread through the homepage: one sentence at the top of each section
- * that leads over from the one before, so the page reads as one story.
+ * The story of the homepage: it follows the questions a visitor has, in the
+ * order they come up. Each chapter opens with its question and ends by
+ * handing over to the next one.
*/
readonly story: {
- readonly services: string;
- readonly automation: string;
- readonly presentations: string;
- readonly working: string;
- readonly work: string;
- readonly faq: string;
+ readonly next: string;
+ /** Label of the rail that shows all chapters. */
+ readonly railLabel: string;
+ readonly chapters: readonly StoryChapter[];
};
/** The page for people who develop themselves or lead a team. */
readonly freelance: {
diff --git a/apps/web/src/domain/chapters.spec.ts b/apps/web/src/domain/chapters.spec.ts
new file mode 100644
index 0000000..6c1cfe4
--- /dev/null
+++ b/apps/web/src/domain/chapters.spec.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { chapterAt, storyProgress } from "./chapters";
+
+const TOPS = [1000, 2000, 5000];
+
+describe("chapterAt", () => {
+ it("is no chapter above the first one", () => {
+ expect(chapterAt(TOPS, 0)).toBe(-1);
+ expect(chapterAt(TOPS, 999)).toBe(-1);
+ });
+
+ it("is the chapter whose top was passed last", () => {
+ expect(chapterAt(TOPS, 1000)).toBe(0);
+ expect(chapterAt(TOPS, 1999)).toBe(0);
+ expect(chapterAt(TOPS, 2000)).toBe(1);
+ expect(chapterAt(TOPS, 4999)).toBe(1);
+ expect(chapterAt(TOPS, 5000)).toBe(2);
+ expect(chapterAt(TOPS, 99999)).toBe(2);
+ });
+
+ it("is no chapter on a page without chapters", () => {
+ expect(chapterAt([], 500)).toBe(-1);
+ });
+});
+
+describe("storyProgress", () => {
+ it("is 0 up to the first chapter and 1 from the end on", () => {
+ expect(storyProgress(TOPS, 6000, 0)).toBe(0);
+ expect(storyProgress(TOPS, 6000, 1000)).toBe(0);
+ expect(storyProgress(TOPS, 6000, 6000)).toBe(1);
+ expect(storyProgress(TOPS, 6000, 7000)).toBe(1);
+ });
+
+ it("gives every chapter the same share, however long it is", () => {
+ // Three chapters: each owns a third.
+ expect(storyProgress(TOPS, 6000, 2000)).toBeCloseTo(1 / 3);
+ expect(storyProgress(TOPS, 6000, 5000)).toBeCloseTo(2 / 3);
+ });
+
+ it("moves evenly within a chapter", () => {
+ // Half way through the first chapter (1000 to 2000).
+ expect(storyProgress(TOPS, 6000, 1500)).toBeCloseTo(1 / 6);
+ // A quarter into the long second chapter (2000 to 5000).
+ expect(storyProgress(TOPS, 6000, 2750)).toBeCloseTo(1 / 3 + 1 / 12);
+ // Half way through the last one, which ends at `end` (5000 to 6000).
+ expect(storyProgress(TOPS, 6000, 5500)).toBeCloseTo(2 / 3 + 1 / 6);
+ });
+
+ it("is 0 on a page without chapters", () => {
+ expect(storyProgress([], 6000, 3000)).toBe(0);
+ });
+
+ it("skips a chapter without height: the one after it is the current one", () => {
+ // Two chapters start at the same offset; the second one runs to the end.
+ expect(storyProgress([1000, 1000], 2000, 1000)).toBeCloseTo(1 / 2);
+ expect(storyProgress([1000, 1000], 2000, 1500)).toBeCloseTo(3 / 4);
+ });
+
+ it("treats a last chapter without height as passed at once", () => {
+ expect(storyProgress([1000], 1000, 1000)).toBe(1);
+ expect(storyProgress([1000, 3000], 3000, 3000)).toBe(1);
+ // An end above the chapter's start is no height either.
+ expect(storyProgress([1000], 500, 1200)).toBe(1);
+ });
+});
diff --git a/apps/web/src/domain/chapters.ts b/apps/web/src/domain/chapters.ts
new file mode 100644
index 0000000..fd35700
--- /dev/null
+++ b/apps/web/src/domain/chapters.ts
@@ -0,0 +1,27 @@
+/**
+ * Where a visitor is in the story of the homepage. `tops` are the page offsets
+ * at which the chapters start, in order; `y` is the offset the visitor reads at.
+ */
+
+/** Index of the chapter the visitor is in, or -1 above the first chapter. */
+export function chapterAt(tops: readonly number[], y: number): number {
+ return tops.filter((top) => top <= y).length - 1;
+}
+
+/**
+ * How far the visitor is through the story, from 0 to 1. Every chapter owns the
+ * same share however long it is, so the marker passes the stations of a rail
+ * with even spacing exactly when the chapters begin. The last chapter ends at `end`.
+ */
+export function storyProgress(
+ tops: readonly number[],
+ end: number,
+ y: number,
+): number {
+ const index = chapterAt(tops, y);
+ const from = tops[index];
+ if (from === undefined) return 0;
+ const to = tops[index + 1] ?? end;
+ const within = to > from ? Math.min(1, (y - from) / (to - from)) : 1;
+ return (index + within) / tops.length;
+}