From 3e0c9a160530522ab54e2444d060a5d439af03e3 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 5 Oct 2026 10:35:55 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 14 +- apps/web/app/[locale]/page.tsx | 39 +--- apps/web/app/globals.css | 41 ++-- apps/web/src/components/Header.tsx | 7 +- apps/web/src/components/home/Block.tsx | 23 +- apps/web/src/components/home/ClosingCta.tsx | 19 +- .../src/components/home/PortraitSection.tsx | 9 +- apps/web/src/components/story/Chapter.tsx | 61 ----- .../web/src/components/story/ChapterIntro.tsx | 36 +++ apps/web/src/components/story/StoryGuide.tsx | 139 ------------ apps/web/src/components/story/StoryThread.tsx | 211 ++++++++++++++++++ apps/web/src/components/story/threadPath.ts | 131 +++++++++++ apps/web/src/components/story/useChapter.ts | 74 ------ apps/web/src/content/de.ts | 34 +-- apps/web/src/content/en.ts | 35 +-- apps/web/src/content/types.ts | 20 +- apps/web/src/domain/chapters.spec.ts | 65 ------ apps/web/src/domain/chapters.ts | 27 --- apps/web/src/domain/thread.spec.ts | 39 ++++ apps/web/src/domain/thread.ts | 23 ++ 20 files changed, 539 insertions(+), 508 deletions(-) delete mode 100644 apps/web/src/components/story/Chapter.tsx create mode 100644 apps/web/src/components/story/ChapterIntro.tsx delete mode 100644 apps/web/src/components/story/StoryGuide.tsx create mode 100644 apps/web/src/components/story/StoryThread.tsx create mode 100644 apps/web/src/components/story/threadPath.ts delete mode 100644 apps/web/src/components/story/useChapter.ts delete mode 100644 apps/web/src/domain/chapters.spec.ts delete mode 100644 apps/web/src/domain/chapters.ts create mode 100644 apps/web/src/domain/thread.spec.ts create mode 100644 apps/web/src/domain/thread.ts diff --git a/CLAUDE.md b/CLAUDE.md index a1748be..f1a9769 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index 93a09c6..2774bb7 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -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 (
-

{hello.lead} @@ -119,14 +102,13 @@ export default async function LandingPage({ params }: Props) { {hello.careerLabel}

- {afterHello && }
} > @@ -137,7 +119,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -170,7 +152,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -243,7 +225,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -294,7 +276,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -309,7 +291,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -317,7 +299,8 @@ export default async function LandingPage({ params }: Props) { - + + ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index e4dae52..d1c2e79 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; } /* diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index 6013bc3..25bf4a5 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -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 = () => { - - } - /> +
= ({ label, title, chapter, - next, id, tone = "light", children, @@ -38,8 +36,7 @@ export const Block: React.FC = ({ const dark = tone === "dark"; return (
= ({
{chapter && (
- +
)} @@ -92,14 +89,6 @@ 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 8c23fae..cec2746 100644 --- a/apps/web/src/components/home/ClosingCta.tsx +++ b/apps/web/src/components/home/ClosingCta.tsx @@ -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 }) => (
@@ -29,6 +30,16 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
+ {story && ( +

+

+ )} = ({ id, index, label, title, portraitAlt, children }) => ( +}> = ({ id, index, label, title, portraitAlt, story, children }) => (
{/* 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] }} > +

+ {story} +

{index} diff --git a/apps/web/src/components/story/Chapter.tsx b/apps/web/src/components/story/Chapter.tsx deleted file mode 100644 index 10c677b..0000000 --- a/apps/web/src/components/story/Chapter.tsx +++ /dev/null @@ -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, -}) => ( -