feat: a red thread through the homepage, in words and as a line

The rail and the "next, you are wondering" links are gone (owner: flat, and
the question stood twice in a row). The page now reads from top to bottom:

Every chapter opens with one sentence, and the sentences follow each other
as the way of a project: you need someone who builds it, first we find out
what you need, sometimes it is less work by hand, sometimes a meeting, then
it always runs the same way, in the end something runs, a few questions
remain, and it starts with an email.

One line runs down the whole page and draws itself as the visitor scrolls.
Under each sentence it crosses the page, stands a figure on the line
(diamond, hexagon, screen, circle, square, triangle) and continues down the
other side; a glowing dot travels at its head and comes to rest in front of
the last sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:35:55 +02:00
co-authored by Claude Opus 5.5
parent 90120b60db
commit 3e0c9a1605
20 changed files with 539 additions and 508 deletions
+8 -6
View File
@@ -63,12 +63,14 @@ 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.
- 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.
- The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens
with one sentence from `story` in the content files; read one after the other they tell the way of a
project from the idea to the first email. `StoryThread` is one line down the whole page that draws itself
as the visitor scrolls: under each sentence it crosses the page, stands a figure on the line and continues
on the other side, with a glowing dot at its head. `Block` prop `chapter` gives a section its sentence and
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).
- `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`).
+11 -28
View File
@@ -18,11 +18,10 @@ 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 { StoryThread } from "@/src/components/story/StoryThread";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
import type { ChapterId, SplitTitle } from "@/src/content/types";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
@@ -70,28 +69,11 @@ 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 (
<div className="relative flex flex-col bg-white">
<HomeJsonLd locale={locale} />
<HeroStatement hero={content.hero} />
<StickyBook />
<StoryGuide />
<PortraitSection
id="hello"
@@ -99,6 +81,7 @@ export default async function LandingPage({ params }: Props) {
label={hello.label}
title={hello.title}
portraitAlt={hello.portraitAlt}
story={story.hello}
>
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
{hello.lead}
@@ -119,14 +102,13 @@ export default async function LandingPage({ params }: Props) {
{hello.careerLabel}
</p>
<CareerLine stations={hello.career} />
{afterHello && <NextChapter {...afterHello} dark />}
</div>
</section>
<Block
index="02"
label={services.label}
{...chapter("services")}
chapter={{ line: story.services, figure: "diamond", flip: false }}
title={<Title title={services.title} />}
>
<Reveal>
@@ -137,7 +119,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="03"
label={automation.label}
{...chapter("automation")}
chapter={{ line: story.automation, figure: "hexagon", flip: true }}
title={<Title title={automation.title} />}
>
<Reveal>
@@ -170,7 +152,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="04"
label={presentations.label}
{...chapter("presentations")}
chapter={{ line: story.presentations, figure: "screen", flip: false }}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
@@ -243,7 +225,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="05"
label={working.label}
{...chapter("working")}
chapter={{ line: story.working, figure: "circle", flip: true }}
title={<Title title={working.title} />}
>
<Reveal>
@@ -294,7 +276,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="06"
label={work.label}
{...chapter("work")}
chapter={{ line: story.work, figure: "square", flip: false }}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
@@ -309,7 +291,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="07"
label={faq.label}
{...chapter("faq")}
chapter={{ line: story.faq, figure: "triangle", flip: true }}
title={<Title title={faq.title} />}
>
<Reveal>
@@ -317,7 +299,8 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<ClosingCta contact={content.contact} />
<ClosingCta contact={content.contact} story={story.contact} />
<StoryThread />
</div>
);
}
+27 -14
View File
@@ -1339,21 +1339,34 @@ pre:has(code[class*="language-"]) {
}
}
/* The story guide: the dot that travels along the chapter rail, and the header line. */
.story-dot {
/*
* The story thread (see StoryThread): one line down the page, indigo on light
* sections and light on dark ones, with a glowing dot at its head.
*/
.story-thread path {
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.story-thread-light {
stroke: #4f46e5;
}
.story-thread-dark {
stroke: #c7d2fe;
stroke-width: 2;
}
.story-thread-dot {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-radius: 9999px;
background: #fde68a;
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);
}
0 0 0 4px rgba(253, 230, 138, 0.28),
0 0 18px 5px rgba(251, 191, 36, 0.85),
0 0 44px 14px rgba(129, 140, 248, 0.55);
transition: opacity 300ms ease;
will-change: transform;
}
/*
+1 -6
View File
@@ -9,7 +9,6 @@ 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 = () => {
@@ -62,11 +61,7 @@ export const Header: React.FC = () => {
</div>
</Link>
<ChapterNow
fallback={
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
}
/>
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<div className="flex items-center gap-2.5 md:gap-4">
<Link
+6 -17
View File
@@ -4,17 +4,16 @@ 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";
import { ChapterIntro } from "@/src/components/story/ChapterIntro";
import type { Figure } from "@/src/components/story/threadPath";
interface BlockProps {
/** Two-digit section index, shown with its binary form. */
index: string;
label: string;
title: React.ReactNode;
/** 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 };
/** Makes the section a chapter of the story: its sentence and the figure the thread forms. */
chapter?: { line: string; figure: Figure; flip: boolean };
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
@@ -30,7 +29,6 @@ export const Block: React.FC<BlockProps> = ({
label,
title,
chapter,
next,
id,
tone = "light",
children,
@@ -38,8 +36,7 @@ export const Block: React.FC<BlockProps> = ({
const dark = tone === "dark";
return (
<section
id={chapter?.id ?? id}
data-chapter={chapter?.id}
id={id}
className={`relative scroll-mt-24 border-t ${
dark
? "overflow-clip border-slate-800 bg-slate-900"
@@ -60,7 +57,7 @@ export const Block: React.FC<BlockProps> = ({
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
{chapter && (
<div className="-mt-14 md:-mt-36">
<ChapterThread dark={dark} />
<ChapterIntro {...chapter} dark={dark} />
</div>
)}
<Reveal>
@@ -92,14 +89,6 @@ export const Block: React.FC<BlockProps> = ({
</h2>
</BitWipe>
<div className="mt-10 md:mt-24">{children}</div>
{next && (
<NextChapter
label={next.label}
id={next.id}
question={next.question}
dark={dark}
/>
)}
</div>
</section>
);
+15 -4
View File
@@ -8,12 +8,13 @@ import { BitHeadline } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
contact,
}) => (
export const ClosingCta: React.FC<{
contact: Content["contact"];
/** The last sentence of the story; the story thread ends in front of it. */
story?: string;
}> = ({ contact, story }) => (
<section
id="contact"
data-chapter="contact"
data-glow
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
>
@@ -29,6 +30,16 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 py-16 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
{story && (
<p className="relative m-0 mb-10 max-w-xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-14 md:text-5xl">
<span
data-thread-end=""
className="absolute -left-2.5 top-[0.55em] md:-left-5"
aria-hidden="true"
/>
{story}
</p>
)}
<BitHeadline
as="h2"
invert
@@ -18,11 +18,13 @@ export const PortraitSection: React.FC<{
label: string;
title: React.ReactNode;
portraitAlt: string;
/** The sentence of the story this chapter opens with. */
story: string;
children: React.ReactNode;
}> = ({ id, index, label, title, portraitAlt, children }) => (
}> = ({ id, index, label, title, portraitAlt, story, children }) => (
<section
id={id}
data-chapter={id}
data-thread-start=""
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
>
{/* Portrait: right side on wide screens, top on small ones. */}
@@ -59,6 +61,9 @@ export const PortraitSection: React.FC<{
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<p className="m-0 mb-10 max-w-2xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-16 md:text-5xl">
{story}
</p>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-white">
{index}
-61
View File
@@ -1,61 +0,0 @@
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,
}) => (
<span
className={`mb-10 block h-14 w-px md:mb-16 md:h-28 ${dark ? "bg-slate-600" : "bg-slate-300"}`}
aria-hidden="true"
/>
);
/**
* 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 }) => (
<Reveal>
<a
href={`#${id}`}
className={`group mt-16 flex items-end justify-between gap-6 border-t pt-8 no-underline md:mt-28 md:pt-12 ${
dark ? "border-slate-700" : "border-slate-200"
}`}
>
<span>
<span
className={`block font-mono text-[11px] uppercase tracking-[0.25em] ${dark ? "text-slate-400" : "text-slate-500"}`}
>
{label}
</span>
<span
className={`mt-3 block font-sans text-2xl font-bold leading-tight tracking-tight md:text-5xl ${
dark ? "text-white" : "text-slate-900"
}`}
>
{question}
</span>
</span>
<span
aria-hidden="true"
className={`grid h-12 w-12 shrink-0 place-items-center rounded-full border text-xl transition-all duration-300 group-hover:translate-y-1 md:h-16 md:w-16 md:text-2xl ${
dark
? "border-slate-600 text-white group-hover:border-white group-hover:bg-white group-hover:text-slate-900"
: "border-slate-300 text-slate-900 group-hover:border-slate-900 group-hover:bg-slate-900 group-hover:text-white"
}`}
>
↓
</span>
</a>
</Reveal>
);
@@ -0,0 +1,36 @@
import * as React from "react";
import type { Figure } from "./threadPath";
/**
* The opening of a chapter: one sentence of the story, and the box in which
* the story thread stands its figure. The thread runs along the bottom edge of
* this row, so the sentence stands on the line. `flip` mirrors the row; the
* thread alternates sides from chapter to chapter.
*/
export const ChapterIntro: React.FC<{
line: string;
figure: Figure;
flip: boolean;
dark?: boolean;
}> = ({ line, figure, flip, dark = false }) => (
<div
className={`mb-12 flex flex-col pt-20 md:mb-24 md:items-end md:gap-16 md:pt-40 ${
flip ? "md:flex-row-reverse" : "md:flex-row"
}`}
>
<p
className={`order-2 m-0 mt-6 max-w-3xl font-sans text-2xl font-bold leading-[1.1] tracking-tight md:order-none md:mt-0 md:pb-6 md:text-5xl ${
flip ? "md:ml-auto md:text-right" : ""
} ${dark ? "text-white" : "text-slate-900"}`}
>
{line}
</p>
<span
data-thread-figure={figure}
className={`order-1 block h-[72px] w-[72px] shrink-0 md:order-none md:h-[132px] md:w-[132px] ${
flip ? "self-start md:mr-auto md:self-end" : "self-end md:ml-auto"
}`}
aria-hidden="true"
/>
</div>
);
@@ -1,139 +0,0 @@
"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 (
<>
<nav
aria-label={story.railLabel}
className={`fixed left-3 top-1/2 z-[80] hidden -translate-y-1/2 rounded-full border border-slate-200 bg-white/85 px-3 py-5 shadow-lg shadow-slate-900/10 backdrop-blur-md transition-all duration-500 lg:block ${
shown ? "opacity-100" : "pointer-events-none -translate-x-4 opacity-0"
}`}
>
<div className="relative w-3" style={{ height: RAIL_HEIGHT }}>
<span
className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-slate-200"
aria-hidden="true"
/>
<span
className="absolute left-1/2 top-0 w-px -translate-x-1/2 bg-slate-900"
style={{ height: `${along * 100}%` }}
aria-hidden="true"
/>
{chapters.map((chapter, i) => {
const passed = i <= index;
const active = i === index;
return (
<button
key={chapter.id}
type="button"
aria-current={active ? "step" : undefined}
onClick={() => goTo(chapter.id)}
className="group absolute left-1/2 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center"
style={{ top: `${(i / last) * 100}%` }}
>
<span
className={`block rounded-full border transition-all duration-300 ${
active
? "h-3 w-3 border-slate-900 bg-slate-900"
: passed
? "h-2 w-2 border-slate-900 bg-slate-900"
: "h-2 w-2 border-slate-300 bg-white group-hover:border-slate-900"
}`}
/>
<span
className={`pointer-events-none absolute left-full ml-4 whitespace-nowrap rounded-full bg-slate-900 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-white transition-opacity duration-200 ${
active
? "opacity-0 group-hover:opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
>
{String(i + 1).padStart(2, "0")} · {chapter.short}
</span>
</button>
);
})}
<span
className="story-dot absolute left-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-300"
style={{ top: `${along * 100}%` }}
aria-hidden="true"
/>
</div>
</nav>
{/* Phones: where you are, and one tap to the next chapter. */}
<button
type="button"
onClick={() => next && goTo(next.id)}
disabled={!next}
className={`fixed bottom-5 left-4 z-[80] flex items-center gap-3 rounded-full border border-slate-200 bg-white/90 py-2 pl-4 pr-2 shadow-lg shadow-slate-900/10 backdrop-blur-md transition-all duration-500 lg:hidden ${
shown && current
? "opacity-100"
: "pointer-events-none translate-y-4 opacity-0"
}`}
>
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-slate-900">
{index + 1}/{chapters.length} · {current?.short}
</span>
{next && (
<span className="flex items-center gap-2 rounded-full bg-slate-900 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.16em] text-white">
{next.short}
<span aria-hidden="true">↓</span>
</span>
)}
</button>
</>
);
};
/** 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 (
<span
key={current.id}
className="story-now hidden items-center gap-3 font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:flex"
>
<span className="font-bold text-slate-900">
{String(index + 1).padStart(2, "0")}/
{String(chapters.length).padStart(2, "0")}
</span>
<span className="h-px w-6 bg-slate-300" aria-hidden="true" />
{current.question}
</span>
);
};
@@ -0,0 +1,211 @@
"use client";
import * as React from "react";
import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread";
import { threadPath, type Box, type Figure, type Stop } from "./threadPath";
/** Marks the box a chapter's figure stands in; the value is the figure. */
export const FIGURE_ATTRIBUTE = "data-thread-figure";
/** Marks the point the line ends at. */
export const END_ATTRIBUTE = "data-thread-end";
/** Marks the section the line starts in. */
export const START_ATTRIBUTE = "data-thread-start";
/** Distance between the samples used to map scroll depth to drawn length, in px. */
const SAMPLE_STEP = 10;
/** The line is drawn down to this share of the window height. */
const READING_LINE = 0.66;
/** How quickly the drawing catches up with the scroll position, per frame. */
const FOLLOW = 0.075;
const CONTENT_WIDTH = 1400;
type Layout = {
readonly width: number;
readonly height: number;
readonly d: string;
readonly dark: readonly Box[];
};
const FIGURES: readonly Figure[] = [
"diamond",
"hexagon",
"screen",
"circle",
"square",
"triangle",
];
const isFigure = (value: string | null): value is Figure =>
FIGURES.some((figure) => figure === value);
/**
* StoryThread: one line of light that runs down the whole homepage and draws
* itself as the visitor scrolls. Under each chapter's sentence it crosses the
* page, stands a figure on the line, and continues on the other side, so the
* eye is led from chapter to chapter. A glowing dot travels at its head.
* Put it last inside the `relative` wrapper that holds the sections.
*/
export const StoryThread: React.FC = () => {
const svgRef = React.useRef<SVGSVGElement>(null);
const lightRef = React.useRef<SVGPathElement>(null);
const darkRef = React.useRef<SVGPathElement>(null);
const dotRef = React.useRef<HTMLSpanElement>(null);
const clipId = React.useId();
const [layout, setLayout] = React.useState<Layout | null>(null);
// Measure the page: where the figures stand, where the dark sections are.
React.useEffect(() => {
const wrapper = svgRef.current?.parentElement;
if (!wrapper) return;
let timer = 0;
const measure = () => {
const origin = wrapper.getBoundingClientRect();
const boxOf = (element: Element): Box => {
const rect = element.getBoundingClientRect();
return {
x: rect.left - origin.left,
y: rect.top - origin.top,
width: rect.width,
height: rect.height,
};
};
const stops: Stop[] = [];
wrapper.querySelectorAll(`[${FIGURE_ATTRIBUTE}]`).forEach((element) => {
const figure = element.getAttribute(FIGURE_ATTRIBUTE);
const box = boxOf(element);
// Hidden panels have no size: they are not on the visitor's way.
if (isFigure(figure) && box.width > 0) stops.push({ figure, box });
});
const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`);
const first = stops[0];
if (!startSection || !first) {
setLayout(null);
return;
}
// The margins: half way between the edge of the window and the content.
const padding = origin.width >= 768 ? 48 : 20;
const margin =
(Math.max(0, origin.width - CONTENT_WIDTH) / 2 + padding) / 2;
const left = Math.round(margin);
const right = Math.round(origin.width - margin);
const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`);
const endBox = endElement ? boxOf(endElement) : null;
setLayout({
width: origin.width,
height: origin.height,
d: threadPath({
start: [left, boxOf(startSection).y],
stops,
end: endBox ? [endBox.x, endBox.y] : null,
left,
right,
}),
dark: Array.from(wrapper.querySelectorAll("section.bg-slate-900")).map(
boxOf,
),
});
};
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(measure, 120);
};
measure();
const observer = new ResizeObserver(schedule);
observer.observe(wrapper);
return () => {
observer.disconnect();
window.clearTimeout(timer);
};
}, []);
// Draw as far as the visitor has read, and move the dot to the head of the line.
React.useEffect(() => {
const svg = svgRef.current;
const light = lightRef.current;
const dark = darkRef.current;
const dot = dotRef.current;
if (!layout || !svg || !light || !dark || !dot) return;
const total = light.getTotalLength();
const ys: number[] = [];
for (let at = 0; at <= total; at += SAMPLE_STEP) {
ys.push(light.getPointAtLength(at).y);
}
const depths = deepestSoFar(ys);
for (const path of [light, dark]) {
path.style.strokeDasharray = `${total}`;
}
const show = (length: number) => {
const offset = `${total - length}`;
light.style.strokeDashoffset = offset;
dark.style.strokeDashoffset = offset;
const head = light.getPointAtLength(length);
dot.style.transform = `translate(${head.x}px, ${head.y}px)`;
dot.style.opacity = length > 0 && length < total ? "1" : "0";
};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
show(total);
dot.style.opacity = "0";
return;
}
let frame = 0;
let drawn = 0;
const target = () => {
const top = svg.getBoundingClientRect().top;
const depth = window.innerHeight * READING_LINE - top;
return Math.min(total, lengthAtDepth(depths, SAMPLE_STEP, depth));
};
const tick = () => {
frame = window.requestAnimationFrame(tick);
const gap = target() - drawn;
if (Math.abs(gap) < 0.5) return;
drawn += gap * FOLLOW;
show(drawn);
};
show(0);
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [layout]);
return (
<>
<svg
ref={svgRef}
aria-hidden="true"
width={layout?.width ?? 0}
height={layout?.height ?? 0}
className="story-thread pointer-events-none absolute left-0 top-0 z-10"
fill="none"
>
{layout && (
<>
<defs>
<clipPath id={clipId}>
{layout.dark.map((box) => (
<rect key={`${box.y}-${box.height}`} {...box} />
))}
</clipPath>
</defs>
<path ref={lightRef} d={layout.d} className="story-thread-light" />
<path
ref={darkRef}
d={layout.d}
className="story-thread-dark"
clipPath={`url(#${clipId})`}
/>
</>
)}
</svg>
<span
ref={dotRef}
aria-hidden="true"
className="story-thread-dot pointer-events-none absolute left-0 top-0 z-10 opacity-0"
/>
</>
);
};
+131
View File
@@ -0,0 +1,131 @@
/**
* Geometry of the story thread: one continuous line down the page. At every
* chapter it runs across the full width under the chapter's sentence, stands a
* figure on that line, and continues down the other side.
*/
export type Figure =
| "diamond"
| "hexagon"
| "screen"
| "circle"
| "square"
| "triangle";
export type Box = {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
};
export type Stop = {
readonly figure: Figure;
/** Where the figure stands; the line runs along the bottom edge of this box. */
readonly box: Box;
};
type Point = readonly [number, number];
/**
* Closed figures in a 100 x 100 box, starting and ending at the bottom centre
* (50, 100) and leaving it to the right. The line arrives there, goes once
* round the figure, and carries on.
*/
const POLYGONS: Readonly<Record<Exclude<Figure, "circle">, readonly Point[]>> =
{
diamond: [
[95, 55],
[50, 10],
[5, 55],
[50, 100],
],
hexagon: [
[75, 100],
[98, 57],
[75, 14],
[25, 14],
[2, 57],
[25, 100],
[50, 100],
],
screen: [
[98, 100],
[98, 36],
[2, 36],
[2, 100],
[50, 100],
],
square: [
[90, 100],
[90, 20],
[10, 20],
[10, 100],
[50, 100],
],
triangle: [
[98, 100],
[50, 14],
[2, 100],
[50, 100],
],
};
const CORNER = 36;
const round = (value: number): number => Math.round(value * 10) / 10;
/** The figure as path commands. `direction` is 1 when the line arrives heading right, -1 heading left. */
const figurePath = (figure: Figure, box: Box, direction: 1 | -1): string => {
const place = ([ux, uy]: Point): string => {
const x = direction === 1 ? ux : 100 - ux;
return `${round(box.x + (x / 100) * box.width)} ${round(box.y + (uy / 100) * box.height)}`;
};
if (figure === "circle") {
// Four quarter arcs of a circle with centre (50, 55) and radius 45.
const k = 24.85;
return [
`C${place([50 + k, 100])} ${place([95, 55 + k])} ${place([95, 55])}`,
`C${place([95, 55 - k])} ${place([50 + k, 10])} ${place([50, 10])}`,
`C${place([50 - k, 10])} ${place([5, 55 - k])} ${place([5, 55])}`,
`C${place([5, 55 + k])} ${place([50 - k, 100])} ${place([50, 100])}`,
].join("");
}
return POLYGONS[figure].map((point) => `L${place(point)}`).join("");
};
/**
* The whole line. It starts at `start` in the left margin, visits the stops in
* order (arriving from the left at the first, from the right at the second, and
* so on), and ends at `end`. `left` and `right` are the x of the two margins.
*/
export const threadPath = (thread: {
readonly start: Point;
readonly stops: readonly Stop[];
readonly end: Point | null;
readonly left: number;
readonly right: number;
}): string => {
const { left, right } = thread;
let d = `M${round(thread.start[0])} ${round(thread.start[1])}`;
let side = left;
thread.stops.forEach((stop, index) => {
const direction = index % 2 === 0 ? 1 : -1;
const from = direction === 1 ? left : right;
const to = direction === 1 ? right : left;
const y = round(stop.box.y + stop.box.height);
const foot = round(stop.box.x + stop.box.width / 2);
d +=
`L${from} ${y - CORNER}Q${from} ${y} ${from + direction * CORNER} ${y}` +
`L${foot} ${y}` +
figurePath(stop.figure, stop.box, direction) +
`L${to - direction * CORNER} ${y}Q${to} ${y} ${to} ${y + CORNER}`;
side = to;
});
if (thread.end) {
const [x, y] = thread.end;
d += `L${side} ${round(y - CORNER)}Q${side} ${round(y)} ${round(x)} ${round(y)}`;
}
return d;
};
@@ -1,74 +0,0 @@
"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<StoryPosition>(NOWHERE);
React.useEffect(() => {
let frame = 0;
const measure = () => {
frame = 0;
const sections = Array.from(
document.querySelectorAll<HTMLElement>(`[${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;
};
+10 -24
View File
@@ -484,30 +484,16 @@ export const de: Content = {
cta: "Nach einer Präsentation fragen",
},
story: {
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?" },
],
hello: "Für ein Vorhaben im Web brauchen Sie jemanden, der es baut.",
services: "Als Erstes klären wir, was Sie wirklich brauchen.",
automation:
"Manchmal ist es gar keine neue Software, sondern weniger Handarbeit.",
presentations:
"Und manchmal ist es ein Termin, bei dem Sie überzeugen müssen.",
working: "Wenn klar ist, was entsteht, läuft es immer gleich ab.",
work: "Am Ende steht etwas, das läuft.",
faq: "Vorher bleiben meist ein paar Fragen.",
contact: "Und dann beginnt es mit einer E-Mail.",
},
freelance: {
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
+11 -24
View File
@@ -472,30 +472,17 @@ export const en: Content = {
cta: "Ask about a presentation",
},
story: {
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?" },
],
hello: "For a project on the web you need someone who builds it.",
services: "First we find out what you really need.",
automation:
"Sometimes it is not new software at all, but less work by hand.",
presentations:
"And sometimes it is a meeting in which you have to convince.",
working:
"Once it is clear what will be built, it always runs the same way.",
work: "In the end there is something that runs.",
faq: "Before that, a few questions usually remain.",
contact: "And then it starts with an email.",
},
freelance: {
metaTitle: "Freelance web developer for your team | Marc Mintel",
+3 -17
View File
@@ -130,14 +130,6 @@ export type ChapterId =
| "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;
@@ -249,16 +241,10 @@ export type Content = {
readonly cta: string;
};
/**
* 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.
* The story of the homepage: one sentence per chapter. Read one after the
* other they tell the way of a project from the idea to the first email.
*/
readonly story: {
readonly next: string;
/** Label of the rail that shows all chapters. */
readonly railLabel: string;
readonly chapters: readonly StoryChapter[];
};
readonly story: Readonly<Record<ChapterId, string>>;
/** The page for people who develop themselves or lead a team. */
readonly freelance: {
readonly metaTitle: string;
-65
View File
@@ -1,65 +0,0 @@
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);
});
});
-27
View File
@@ -1,27 +0,0 @@
/**
* 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;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { deepestSoFar, lengthAtDepth } from "./thread";
describe("deepestSoFar", () => {
it("keeps the lowest point the line has reached at each sample", () => {
// The line goes down, loops back up, and goes down again.
expect(deepestSoFar([10, 5, 20, 15, 30])).toEqual([10, 10, 20, 20, 30]);
});
it("is empty for an empty line", () => {
expect(deepestSoFar([])).toEqual([]);
});
it("works above the top of the page", () => {
expect(deepestSoFar([-20, -30, -10])).toEqual([-20, -20, -10]);
});
});
describe("lengthAtDepth", () => {
const depths = [10, 10, 20, 20, 30];
it("draws nothing above the start of the line", () => {
expect(lengthAtDepth(depths, 8, 9)).toBe(0);
});
it("draws everything the line needed to reach that depth, loops included", () => {
expect(lengthAtDepth(depths, 8, 10)).toBe(16);
expect(lengthAtDepth(depths, 8, 19)).toBe(16);
expect(lengthAtDepth(depths, 8, 20)).toBe(32);
});
it("draws the whole line below its end", () => {
expect(lengthAtDepth(depths, 8, 1000)).toBe(40);
});
it("draws nothing of an empty line", () => {
expect(lengthAtDepth([], 8, 1000)).toBe(0);
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* The story thread is a line that runs down the homepage and draws itself as
* the visitor scrolls. It is not straight: it loops back up to form figures.
* These two functions decide how much of it is drawn at a scroll depth.
*/
/** For each sample along the line: the lowest point the line has reached so far. */
export function deepestSoFar(ys: readonly number[]): number[] {
return ys.map((_, index) => Math.max(...ys.slice(0, index + 1)));
}
/**
* How much of the line is drawn when the visitor has read down to `y`.
* `depths` comes from `deepestSoFar`, sampled every `step` along the line.
* A figure that loops back up is drawn in full once its lowest point is passed.
*/
export function lengthAtDepth(
depths: readonly number[],
step: number,
y: number,
): number {
return depths.filter((depth) => depth <= y).length * step;
}