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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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
|
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.
|
`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
|
- The homepage is a guided story (`apps/web/src/components/story/`): it follows the questions a visitor has
|
||||||
while the visitor scrolls; scrolling is the timeline (camera, statements), a second clock keeps things
|
(`story.chapters` in the content files). Each chapter ends with `NextChapter`, the next question as a link
|
||||||
alive when the visitor stops. They are the website form of the films in `apps/video` (same pattern: close
|
that leads on; `StoryGuide` is the rail with one station per chapter and a travelling dot (a pill on
|
||||||
on something abstract, pull back, resolve) and use their approved statements. `OrderFilm` opens the
|
phones), and the header names the current question. Sections become chapters through `data-chapter`
|
||||||
automation chapter. Coordinates rendered on the first frame must be rounded (see `motion.ts`), or server
|
(`Block` prop `chapter`). Where the visitor is, is computed in `src/domain/chapters.ts`. A new section
|
||||||
and browser disagree in the last digit.
|
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.
|
- `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 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`).
|
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BitAura } from "@/src/components/bits/BitAura";
|
import { BitAura } from "@/src/components/bits/BitAura";
|
||||||
import { OrderFilm } from "@/src/components/film/OrderFilm";
|
|
||||||
import { Block } from "@/src/components/home/Block";
|
import { Block } from "@/src/components/home/Block";
|
||||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||||
import { Faq } from "@/src/components/home/Faq";
|
import { Faq } from "@/src/components/home/Faq";
|
||||||
@@ -19,9 +18,11 @@ import {
|
|||||||
} from "@/src/components/illustrations/WebPresentation";
|
} from "@/src/components/illustrations/WebPresentation";
|
||||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||||
import { Reveal } from "@/src/components/Reveal";
|
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 { SwipeRow } from "@/src/components/SwipeRow";
|
||||||
import { contentFor } from "@/src/content";
|
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 { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||||
|
|
||||||
@@ -69,11 +70,28 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
faq,
|
faq,
|
||||||
} = content;
|
} = 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 (
|
return (
|
||||||
<div className="relative flex flex-col bg-white">
|
<div className="relative flex flex-col bg-white">
|
||||||
<HomeJsonLd locale={locale} />
|
<HomeJsonLd locale={locale} />
|
||||||
<HeroStatement hero={content.hero} />
|
<HeroStatement hero={content.hero} />
|
||||||
<StickyBook />
|
<StickyBook />
|
||||||
|
<StoryGuide />
|
||||||
|
|
||||||
<PortraitSection
|
<PortraitSection
|
||||||
id="hello"
|
id="hello"
|
||||||
@@ -101,13 +119,14 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
{hello.careerLabel}
|
{hello.careerLabel}
|
||||||
</p>
|
</p>
|
||||||
<CareerLine stations={hello.career} />
|
<CareerLine stations={hello.career} />
|
||||||
|
{afterHello && <NextChapter {...afterHello} dark />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="02"
|
index="02"
|
||||||
label={services.label}
|
label={services.label}
|
||||||
bridge={story.services}
|
{...chapter("services")}
|
||||||
title={<Title title={services.title} />}
|
title={<Title title={services.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -115,12 +134,10 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
<OrderFilm />
|
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="03"
|
index="03"
|
||||||
label={automation.label}
|
label={automation.label}
|
||||||
bridge={story.automation}
|
{...chapter("automation")}
|
||||||
title={<Title title={automation.title} />}
|
title={<Title title={automation.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -153,7 +170,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="04"
|
index="04"
|
||||||
label={presentations.label}
|
label={presentations.label}
|
||||||
bridge={story.presentations}
|
{...chapter("presentations")}
|
||||||
tone="dark"
|
tone="dark"
|
||||||
title={<Title title={presentations.title} muted="text-slate-500" />}
|
title={<Title title={presentations.title} muted="text-slate-500" />}
|
||||||
>
|
>
|
||||||
@@ -226,7 +243,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="05"
|
index="05"
|
||||||
label={working.label}
|
label={working.label}
|
||||||
bridge={story.working}
|
{...chapter("working")}
|
||||||
title={<Title title={working.title} />}
|
title={<Title title={working.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -277,7 +294,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="06"
|
index="06"
|
||||||
label={work.label}
|
label={work.label}
|
||||||
bridge={story.work}
|
{...chapter("work")}
|
||||||
tone="dark"
|
tone="dark"
|
||||||
title={<Title title={work.title} muted="text-slate-500" />}
|
title={<Title title={work.title} muted="text-slate-500" />}
|
||||||
>
|
>
|
||||||
@@ -292,7 +309,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="07"
|
index="07"
|
||||||
label={faq.label}
|
label={faq.label}
|
||||||
bridge={story.faq}
|
{...chapter("faq")}
|
||||||
title={<Title title={faq.title} />}
|
title={<Title title={faq.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
|||||||
@@ -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.
|
* Native feel: the page should behave like a well-made app on every input type.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DecodeText } from "./bits/DecodeText";
|
|||||||
import { ScrollBits } from "./bits/ScrollBits";
|
import { ScrollBits } from "./bits/ScrollBits";
|
||||||
import { useContent, useLocale } from "./ContentProvider";
|
import { useContent, useLocale } from "./ContentProvider";
|
||||||
import { LanguageSwitch } from "./LanguageSwitch";
|
import { LanguageSwitch } from "./LanguageSwitch";
|
||||||
|
import { ChapterNow } from "./story/StoryGuide";
|
||||||
|
|
||||||
/** Header: the logo, the link to the freelance page, the language switch and one action. */
|
/** Header: the logo, the link to the freelance page, the language switch and one action. */
|
||||||
export const Header: React.FC = () => {
|
export const Header: React.FC = () => {
|
||||||
@@ -61,7 +62,11 @@ export const Header: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
<ChapterNow
|
||||||
|
fallback={
|
||||||
|
<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">
|
<div className="flex items-center gap-2.5 md:gap-4">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useContent } from "@/src/components/ContentProvider";
|
|
||||||
import { ORDER_SHOT, OrderShot } from "./OrderShot";
|
|
||||||
import { ScrollFilm, type FilmClock } from "./ScrollFilm";
|
|
||||||
import { progress } from "./motion";
|
|
||||||
|
|
||||||
/** While which frames each of the five statements stands: hook, reveal, problem, turn, calm. */
|
|
||||||
const SLOTS = [
|
|
||||||
{ from: 0, to: ORDER_SHOT.reveal + 20 },
|
|
||||||
{ from: ORDER_SHOT.reveal + 30, to: ORDER_SHOT.wide + 30 },
|
|
||||||
{ from: ORDER_SHOT.wide + 30, to: ORDER_SHOT.freeze + 20 },
|
|
||||||
{ from: ORDER_SHOT.sink, to: ORDER_SHOT.flow + 20 },
|
|
||||||
{ from: ORDER_SHOT.flow + 20, to: ORDER_SHOT.end },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** The walkers' own time: it runs with the clock and slows to a halt at the freeze. */
|
|
||||||
const Walking: React.FC<{ clock: FilmClock }> = ({ 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 <OrderShot clock={clock} walk={walk.current} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<ScrollFilm
|
|
||||||
frames={ORDER_SHOT.end}
|
|
||||||
captions={captions}
|
|
||||||
description={film.description}
|
|
||||||
stillFrame={ORDER_SHOT.flow + 60}
|
|
||||||
>
|
|
||||||
{(clock) => <Walking clock={clock} />}
|
|
||||||
</ScrollFilm>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 (
|
|
||||||
<g opacity={Math.min(1, opacity)}>
|
|
||||||
{[1, 2, 3, 4, 5].map((k) => {
|
|
||||||
const ghost = at(k * 2.2);
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={k}
|
|
||||||
cx={ghost.x}
|
|
||||||
cy={ghost.y}
|
|
||||||
r={radius * (1 - k * 0.13)}
|
|
||||||
fill={MARK}
|
|
||||||
opacity={0.3 - k * 0.05}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<circle
|
|
||||||
cx={point.x}
|
|
||||||
cy={point.y}
|
|
||||||
r={radius}
|
|
||||||
fill={MARK}
|
|
||||||
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${MARK})` }}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<g transform={`scale(${zoom})`}>
|
|
||||||
{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 (
|
|
||||||
<g key={row}>
|
|
||||||
{drawn > 0 && (
|
|
||||||
<path
|
|
||||||
d={`M${colX(0)} ${y}H${lineEnd}`}
|
|
||||||
stroke={INK}
|
|
||||||
strokeWidth={hair * 1.3}
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity={hero ? 1 : others}
|
|
||||||
style={{
|
|
||||||
filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${LIGHT}, 0.9))`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{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 (
|
|
||||||
<g key={col} opacity={opening ? 1 : others}>
|
|
||||||
<rect
|
|
||||||
x={x - TILE / 2}
|
|
||||||
y={y - TILE / 2}
|
|
||||||
width={TILE}
|
|
||||||
height={TILE}
|
|
||||||
rx={TILE * 0.24}
|
|
||||||
fill={PAPER}
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x={x - TILE / 2}
|
|
||||||
y={y - TILE / 2}
|
|
||||||
width={TILE}
|
|
||||||
height={TILE}
|
|
||||||
rx={TILE * 0.24}
|
|
||||||
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
|
||||||
stroke={touched ? MARK : INK}
|
|
||||||
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
|
||||||
strokeWidth={touched ? hair * 1.6 : hair}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{pulseX !== null && (
|
|
||||||
<circle
|
|
||||||
cx={pulseX}
|
|
||||||
cy={y}
|
|
||||||
r={radius * 0.8}
|
|
||||||
fill={MARK}
|
|
||||||
opacity={(hero ? 1 : others) * flowing}
|
|
||||||
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${MARK})` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hero && (
|
|
||||||
<Dot
|
|
||||||
at={pingPong}
|
|
||||||
radius={radius}
|
|
||||||
zoom={zoom}
|
|
||||||
opacity={shown * (1 - others)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Dot
|
|
||||||
at={onRoute}
|
|
||||||
radius={radius}
|
|
||||||
zoom={zoom}
|
|
||||||
opacity={hero ? shown * others : shown}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<ScrollFilmProps> = ({
|
|
||||||
frames,
|
|
||||||
captions,
|
|
||||||
description,
|
|
||||||
stillFrame,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const trackRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [clock, setClock] = React.useState<FilmClock>({
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
ref={trackRef}
|
|
||||||
className={`relative bg-slate-900 ${still ? "" : "h-[340svh] md:h-[420svh]"}`}
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 flex h-[100svh] flex-col items-center justify-center overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 opacity-70"
|
|
||||||
style={{
|
|
||||||
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
|
|
||||||
backgroundSize: "20px 20px",
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="bit-aurora opacity-60" aria-hidden="true" />
|
|
||||||
|
|
||||||
<svg
|
|
||||||
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
|
|
||||||
role="img"
|
|
||||||
aria-label={description}
|
|
||||||
className="relative mt-16 block max-h-[46svh] w-full max-w-[1100px] flex-1 md:mt-24 md:max-h-[50svh]"
|
|
||||||
style={{
|
|
||||||
maskImage: EDGE_FADE,
|
|
||||||
WebkitMaskImage: EDGE_FADE,
|
|
||||||
maskComposite: "intersect",
|
|
||||||
WebkitMaskComposite: "source-in",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children(clock)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* One statement at a time, in the type of the headlines. */}
|
|
||||||
<div
|
|
||||||
className="relative mt-4 h-[24svh] w-full px-5 text-center md:mt-8"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{captions.map((caption, index) => {
|
|
||||||
const arrive =
|
|
||||||
index === 0
|
|
||||||
? 1
|
|
||||||
: progress(shot, caption.from, caption.from + IN_FRAMES);
|
|
||||||
const leave =
|
|
||||||
index === captions.length - 1
|
|
||||||
? 1
|
|
||||||
: 1 - progress(shot, caption.to - OUT_FRAMES, caption.to);
|
|
||||||
const opacity = still
|
|
||||||
? stillFrame >= caption.from && stillFrame < caption.to
|
|
||||||
? 1
|
|
||||||
: 0
|
|
||||||
: Math.min(arrive, leave);
|
|
||||||
if (opacity <= 0) return null;
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
key={caption.lines.join(" ")}
|
|
||||||
className="absolute inset-x-5 top-0 m-0 font-sans text-[9.5vw] font-black leading-[1.04] tracking-tighter text-white md:text-6xl lg:text-7xl"
|
|
||||||
style={{
|
|
||||||
opacity,
|
|
||||||
transform: `translateY(${(1 - arrive) * 18}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption.lines.map((line, i) => (
|
|
||||||
<span
|
|
||||||
key={line}
|
|
||||||
className={`block ${i > 0 && i === caption.lines.length - 1 ? "text-slate-400" : ""}`}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Where the shot is. */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{captions.map((caption) => (
|
|
||||||
<span
|
|
||||||
key={caption.from}
|
|
||||||
className="h-0.5 w-8 overflow-hidden rounded-full bg-slate-700"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block h-full bg-white"
|
|
||||||
style={{
|
|
||||||
width: `${progress(shot, caption.from, caption.to) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The statements as plain text, for screen readers and without JavaScript. */}
|
|
||||||
<p className="sr-only">
|
|
||||||
{captions.map((caption) => caption.lines.join(" ")).join(" ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -4,14 +4,17 @@ import { BitSeam } from "@/src/components/bits/BitSeam";
|
|||||||
import { BitWipe } from "@/src/components/bits/BitWipe";
|
import { BitWipe } from "@/src/components/bits/BitWipe";
|
||||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||||
import { Reveal } from "@/src/components/Reveal";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
|
import { ChapterThread, NextChapter } from "@/src/components/story/Chapter";
|
||||||
|
|
||||||
interface BlockProps {
|
interface BlockProps {
|
||||||
/** Two-digit section index, shown with its binary form. */
|
/** Two-digit section index, shown with its binary form. */
|
||||||
index: string;
|
index: string;
|
||||||
label: string;
|
label: string;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
/** One sentence that leads over from the section before (the thread of the page). */
|
/** Makes the section a chapter of the story, reachable under this id. */
|
||||||
bridge?: string;
|
chapter?: { id: string };
|
||||||
|
/** The chapter this one hands over to at its end. */
|
||||||
|
next?: { id: string; label: string; question: string };
|
||||||
id?: string;
|
id?: string;
|
||||||
/** `dark` turns the section into a dark stage. */
|
/** `dark` turns the section into a dark stage. */
|
||||||
tone?: "light" | "dark";
|
tone?: "light" | "dark";
|
||||||
@@ -26,7 +29,8 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
index,
|
index,
|
||||||
label,
|
label,
|
||||||
title,
|
title,
|
||||||
bridge,
|
chapter,
|
||||||
|
next,
|
||||||
id,
|
id,
|
||||||
tone = "light",
|
tone = "light",
|
||||||
children,
|
children,
|
||||||
@@ -34,7 +38,8 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
const dark = tone === "dark";
|
const dark = tone === "dark";
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={chapter?.id ?? id}
|
||||||
|
data-chapter={chapter?.id}
|
||||||
className={`relative scroll-mt-24 border-t ${
|
className={`relative scroll-mt-24 border-t ${
|
||||||
dark
|
dark
|
||||||
? "overflow-clip border-slate-800 bg-slate-900"
|
? "overflow-clip border-slate-800 bg-slate-900"
|
||||||
@@ -53,22 +58,10 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
{dark && <BitSeam />}
|
{dark && <BitSeam />}
|
||||||
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
|
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
|
||||||
{bridge && (
|
{chapter && (
|
||||||
<Reveal>
|
<div className="-mt-14 md:-mt-36">
|
||||||
<div className="-mt-14 mb-10 md:-mt-36 md:mb-20">
|
<ChapterThread dark={dark} />
|
||||||
<span
|
</div>
|
||||||
className={`block h-14 w-px md:h-28 ${dark ? "bg-slate-600" : "bg-slate-300"}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
className={`m-0 mt-5 max-w-2xl font-sans text-xl leading-snug md:text-3xl ${
|
|
||||||
dark ? "text-slate-300" : "text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{bridge}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
)}
|
)}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
||||||
@@ -99,6 +92,14 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
</h2>
|
</h2>
|
||||||
</BitWipe>
|
</BitWipe>
|
||||||
<div className="mt-10 md:mt-24">{children}</div>
|
<div className="mt-10 md:mt-24">{children}</div>
|
||||||
|
{next && (
|
||||||
|
<NextChapter
|
||||||
|
label={next.label}
|
||||||
|
id={next.id}
|
||||||
|
question={next.question}
|
||||||
|
dark={dark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
|
data-chapter="contact"
|
||||||
data-glow
|
data-glow
|
||||||
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
|
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const PortraitSection: React.FC<{
|
|||||||
}> = ({ id, index, label, title, portraitAlt, children }) => (
|
}> = ({ id, index, label, title, portraitAlt, children }) => (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
|
data-chapter={id}
|
||||||
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
|
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
|
||||||
>
|
>
|
||||||
{/* Portrait: right side on wide screens, top on small ones. */}
|
{/* Portrait: right side on wide screens, top on small ones. */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}) => (
|
||||||
|
<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,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 (
|
||||||
|
<>
|
||||||
|
<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,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<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;
|
||||||
|
};
|
||||||
+24
-17
@@ -277,17 +277,6 @@ export const de: Content = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
automation: {
|
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",
|
label: "Prozessautomatisierung",
|
||||||
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
||||||
intro:
|
intro:
|
||||||
@@ -495,12 +484,30 @@ export const de: Content = {
|
|||||||
cta: "Nach einer Präsentation fragen",
|
cta: "Nach einer Präsentation fragen",
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
services: "So viel zu mir. Jetzt zu Ihnen.",
|
next: "Als Nächstes fragen Sie sich",
|
||||||
automation: "Einen dieser Fälle zeige ich genauer.",
|
railLabel: "Kapitel der Seite",
|
||||||
presentations: "Und manchmal ist das Richtige gar keine App.",
|
chapters: [
|
||||||
working: "Egal, was Sie brauchen: So läuft die Zusammenarbeit.",
|
{ id: "hello", short: "Wer", question: "Wer ist das überhaupt?" },
|
||||||
work: "Und so sieht das Ergebnis aus.",
|
{ id: "services", short: "Was", question: "Kann er, was ich brauche?" },
|
||||||
faq: "Bleiben die Fragen, die fast immer kommen.",
|
{
|
||||||
|
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: {
|
freelance: {
|
||||||
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
|
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
|
||||||
|
|||||||
+24
-17
@@ -273,17 +273,6 @@ export const en: Content = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
automation: {
|
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",
|
label: "Process automation",
|
||||||
title: { lead: "Let the systems", rest: "do the copying." },
|
title: { lead: "Let the systems", rest: "do the copying." },
|
||||||
intro:
|
intro:
|
||||||
@@ -483,12 +472,30 @@ export const en: Content = {
|
|||||||
cta: "Ask about a presentation",
|
cta: "Ask about a presentation",
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
services: "That is me. Now to you.",
|
next: "Next, you are wondering",
|
||||||
automation: "One of these cases, shown in more detail.",
|
railLabel: "Chapters of this page",
|
||||||
presentations: "And sometimes the right thing is not an app at all.",
|
chapters: [
|
||||||
working: "Whatever you need: this is how working together goes.",
|
{ id: "hello", short: "Who", question: "Who is this, anyway?" },
|
||||||
work: "And this is what the result looks like.",
|
{ id: "services", short: "What", question: "Can he do what I need?" },
|
||||||
faq: "That leaves the questions that almost always come up.",
|
{
|
||||||
|
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: {
|
freelance: {
|
||||||
metaTitle: "Freelance web developer for your team | Marc Mintel",
|
metaTitle: "Freelance web developer for your team | Marc Mintel",
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ export type WorkCopy = {
|
|||||||
readonly tech: readonly string[];
|
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 = {
|
export type FaqItem = {
|
||||||
readonly question: string;
|
readonly question: string;
|
||||||
readonly answer: string;
|
readonly answer: string;
|
||||||
@@ -194,11 +212,6 @@ export type Content = {
|
|||||||
readonly scenes: SceneLabels;
|
readonly scenes: SceneLabels;
|
||||||
};
|
};
|
||||||
readonly automation: {
|
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 label: string;
|
||||||
readonly title: SplitTitle;
|
readonly title: SplitTitle;
|
||||||
readonly intro: string;
|
readonly intro: string;
|
||||||
@@ -236,16 +249,15 @@ export type Content = {
|
|||||||
readonly cta: string;
|
readonly cta: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The thread through the homepage: one sentence at the top of each section
|
* The story of the homepage: it follows the questions a visitor has, in the
|
||||||
* that leads over from the one before, so the page reads as one story.
|
* order they come up. Each chapter opens with its question and ends by
|
||||||
|
* handing over to the next one.
|
||||||
*/
|
*/
|
||||||
readonly story: {
|
readonly story: {
|
||||||
readonly services: string;
|
readonly next: string;
|
||||||
readonly automation: string;
|
/** Label of the rail that shows all chapters. */
|
||||||
readonly presentations: string;
|
readonly railLabel: string;
|
||||||
readonly working: string;
|
readonly chapters: readonly StoryChapter[];
|
||||||
readonly work: string;
|
|
||||||
readonly faq: string;
|
|
||||||
};
|
};
|
||||||
/** The page for people who develop themselves or lead a team. */
|
/** The page for people who develop themselves or lead a team. */
|
||||||
readonly freelance: {
|
readonly freelance: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user