feat: the story thread looks like a track on a circuit board

Owner: the direction is right, but a smooth line is not developer style;
everything else on the site is binary. The thread is now a trace: straight
runs with 45 degree bends, solder points where it bends or meets a figure, a
second fainter track beside it, figures as components (pins on the
rectangular ones, the chapter number in binary inside), and a bit that flips
between 1 and 0 as it travels at the head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:42:52 +02:00
co-authored by Claude Opus 5.5
parent 3e0c9a1605
commit affd4b7ee7
4 changed files with 292 additions and 74 deletions
+4 -3
View File
@@ -65,9 +65,10 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
`data-glow` surfaces). Reuse them instead of inventing new effects.
- 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
project from the idea to the first email. `StoryThread` is one trace down the whole page, in the look of a circuit
board (45 degree bends, solder points, a second parallel track), that draws itself as the visitor scrolls:
under each sentence it crosses the page, stands a component with the chapter number in binary on the track
and continues on the other side, with a bit (1/0) travelling 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).
+58 -19
View File
@@ -1340,31 +1340,70 @@ pre:has(code[class*="language-"]) {
}
/*
* 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.
* The story thread (see StoryThread): a trace down the page like a track on a
* circuit board. Indigo on light sections, light on dark ones.
*/
.story-thread path {
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
.story-thread {
--thread-ink: #4f46e5;
--thread-ground: #ffffff;
}
.story-thread-light {
stroke: #4f46e5;
.story-thread-on-light {
--thread-ink: #4f46e5;
--thread-ground: #ffffff;
}
.story-thread-dark {
stroke: #c7d2fe;
stroke-width: 2;
.story-thread-on-dark {
--thread-ink: #c7d2fe;
--thread-ground: #0f172a;
}
.story-thread-dot {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-radius: 9999px;
.story-thread path,
.story-thread line {
stroke: var(--thread-ink);
stroke-width: 1.75;
stroke-linecap: square;
stroke-linejoin: miter;
}
/* The second track of the bus. */
.story-thread path.story-thread-bus {
stroke-width: 1;
opacity: 0.38;
}
/* Solder points where the track bends or branches. */
.story-thread-pad {
fill: var(--thread-ground);
stroke: var(--thread-ink);
stroke-width: 1.75;
opacity: 0;
transition: opacity 250ms ease;
}
/* What makes a figure a component: its pins and its number in binary. */
.story-thread-part {
opacity: 0;
transition: opacity 500ms ease 350ms;
}
.story-thread-part text {
fill: var(--thread-ink);
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.14em;
}
/* The bit that travels at the head of the track. */
.story-thread-bit {
display: grid;
place-items: center;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 3px;
background: #fde68a;
color: #0f172a;
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-size: 12px;
font-weight: 700;
line-height: 1;
box-shadow:
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);
0 0 0 3px rgba(253, 230, 138, 0.25),
0 0 18px 4px rgba(251, 191, 36, 0.8),
0 0 40px 12px rgba(129, 140, 248, 0.5);
transition: opacity 300ms ease;
will-change: transform;
}
+177 -44
View File
@@ -2,7 +2,15 @@
import * as React from "react";
import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread";
import { threadPath, type Box, type Figure, type Stop } from "./threadPath";
import {
FIGURE_CENTRE,
FIGURE_PINS,
threadPath,
type Box,
type Figure,
type Point,
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";
@@ -18,12 +26,31 @@ 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;
/** Offset of the second, parallel trace. */
const BUS_OFFSET = 5;
const PAD_RADIUS = 4;
/** The bit at the head of the trace flips after this much travel, in px. */
const FLIP_EVERY = 46;
/** The first chapter with a figure is the second of the page. */
const FIRST_CHAPTER = 2;
type Mark = { readonly x: number; readonly y: number; readonly dark: boolean };
type Part = {
readonly figure: Figure;
readonly box: Box;
readonly dark: boolean;
/** The chapter number as four binary digits. */
readonly label: string;
};
type Layout = {
readonly width: number;
readonly height: number;
readonly d: string;
readonly dark: readonly Box[];
readonly pads: readonly Mark[];
readonly parts: readonly Part[];
};
const FIGURES: readonly Figure[] = [
@@ -37,18 +64,20 @@ const FIGURES: readonly Figure[] = [
const isFigure = (value: string | null): value is Figure =>
FIGURES.some((figure) => figure === value);
const inside = (box: Box, x: number, y: number): boolean =>
x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
/**
* 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.
* StoryThread: one trace that runs down the whole homepage like a track on a
* circuit board and draws itself as the visitor scrolls. Under each chapter's
* sentence it crosses the page, stands a component on the track, and continues
* on the other side, so the eye is led from chapter to chapter. Solder points
* mark its bends, a second track runs beside it, and a bit 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 bitRef = React.useRef<HTMLSpanElement>(null);
const clipId = React.useId();
const [layout, setLayout] = React.useState<Layout | null>(null);
@@ -77,8 +106,7 @@ export const StoryThread: React.FC = () => {
if (isFigure(figure) && box.width > 0) stops.push({ figure, box });
});
const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`);
const first = stops[0];
if (!startSection || !first) {
if (!startSection || stops.length === 0) {
setLayout(null);
return;
}
@@ -90,20 +118,37 @@ export const StoryThread: React.FC = () => {
const right = Math.round(origin.width - margin);
const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`);
const endBox = endElement ? boxOf(endElement) : null;
const dark = Array.from(
wrapper.querySelectorAll("section.bg-slate-900"),
).map(boxOf);
const isDark = (x: number, y: number): boolean =>
dark.some((box) => inside(box, x, y));
const thread = threadPath({
start: [left, boxOf(startSection).y + 1],
stops,
end: endBox ? [endBox.x, endBox.y] : null,
left,
right,
});
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,
),
d: thread.d,
dark,
pads: thread.pads.map(([x, y]: Point) => ({
x,
y,
dark: isDark(x, y),
})),
parts: stops.map((stop, index) => ({
...stop,
dark: isDark(
stop.box.x + stop.box.width / 2,
stop.box.y + stop.box.height / 2,
),
label: (index + FIRST_CHAPTER).toString(2).padStart(4, "0"),
})),
});
};
@@ -120,36 +165,52 @@ export const StoryThread: React.FC = () => {
};
}, []);
// Draw as far as the visitor has read, and move the dot to the head of the line.
// Draw as far as the visitor has read, and move the bit to the head of the trace.
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 bit = bitRef.current;
if (!layout || !svg || !bit) return;
const traces = Array.from(
svg.querySelectorAll<SVGPathElement>("path[data-trace]"),
);
const main = traces[0];
if (!main) return;
const total = light.getTotalLength();
const ys: number[] = [];
const total = main.getTotalLength();
const points: DOMPoint[] = [];
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}`;
points.push(main.getPointAtLength(at));
}
const depths = deepestSoFar(points.map((point) => point.y));
for (const trace of traces) trace.style.strokeDasharray = `${total}`;
// Solder points and components appear once the trace has reached them.
const reached = Array.from(
svg.querySelectorAll<SVGElement>("[data-at-x]"),
).map((element) => {
const x = Number(element.dataset.atX);
const y = Number(element.dataset.atY);
const distances = points.map((point) =>
Math.hypot(point.x - x, point.y - y),
);
const nearest = distances.indexOf(Math.min(...distances));
return { element, length: nearest * SAMPLE_STEP };
});
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";
for (const trace of traces) trace.style.strokeDashoffset = offset;
for (const mark of reached) {
mark.element.style.opacity = length + 2 >= mark.length ? "1" : "0";
}
const head = main.getPointAtLength(length);
bit.style.transform = `translate(${head.x}px, ${head.y}px)`;
bit.style.opacity = length > 0 && length < total - 1 ? "1" : "0";
bit.textContent = Math.floor(length / FLIP_EVERY) % 2 === 0 ? "1" : "0";
};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
show(total);
dot.style.opacity = "0";
return;
}
@@ -172,6 +233,9 @@ export const StoryThread: React.FC = () => {
return () => window.cancelAnimationFrame(frame);
}, [layout]);
const tone = (dark: boolean) =>
dark ? "story-thread-on-dark" : "story-thread-on-light";
return (
<>
<svg
@@ -191,20 +255,89 @@ export const StoryThread: React.FC = () => {
))}
</clipPath>
</defs>
<path ref={lightRef} d={layout.d} className="story-thread-light" />
{/* The main track first: it is the one that is measured. */}
<path
ref={darkRef}
data-trace=""
d={layout.d}
className="story-thread-dark"
clipPath={`url(#${clipId})`}
className="story-thread-on-light"
/>
<path
data-trace=""
d={layout.d}
className="story-thread-on-light story-thread-bus"
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
/>
<g clipPath={`url(#${clipId})`}>
<path
data-trace=""
d={layout.d}
className="story-thread-on-dark"
/>
<path
data-trace=""
d={layout.d}
className="story-thread-on-dark story-thread-bus"
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
/>
</g>
{layout.parts.map((part) => {
const place = ([ux, uy]: Point): Point => [
part.box.x + (ux / 100) * part.box.width,
part.box.y + (uy / 100) * part.box.height,
];
const [labelX, labelY] = place(FIGURE_CENTRE[part.figure]);
return (
<g
key={part.label}
data-at-x={part.box.x + part.box.width / 2}
data-at-y={part.box.y + part.box.height}
className={`story-thread-part ${tone(part.dark)}`}
>
{FIGURE_PINS[part.figure].map(([from, to]) => {
const [x1, y1] = place(from);
const [x2, y2] = place(to);
return (
<line
key={`${from[0]}-${from[1]}-${to[0]}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
/>
);
})}
<text
x={labelX}
y={labelY}
textAnchor="middle"
dominantBaseline="middle"
fontSize={Math.max(9, part.box.width * 0.105)}
>
{part.label}
</text>
</g>
);
})}
{layout.pads.map((pad) => (
<circle
key={`${pad.x}-${pad.y}`}
data-at-x={pad.x}
data-at-y={pad.y}
cx={pad.x}
cy={pad.y}
r={PAD_RADIUS}
className={`story-thread-pad ${tone(pad.dark)}`}
/>
))}
</>
)}
</svg>
<span
ref={dotRef}
ref={bitRef}
aria-hidden="true"
className="story-thread-dot pointer-events-none absolute left-0 top-0 z-10 opacity-0"
className="story-thread-bit pointer-events-none absolute left-0 top-0 z-10 opacity-0"
/>
</>
);
+53 -8
View File
@@ -1,5 +1,5 @@
/**
* Geometry of the story thread: one continuous line down the page. At every
* Geometry of the story thread: one continuous trace 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.
*/
@@ -25,7 +25,7 @@ export type Stop = {
readonly box: Box;
};
type Point = readonly [number, number];
export type Point = readonly [number, number];
/**
* Closed figures in a 100 x 100 box, starting and ending at the bottom centre
@@ -93,8 +93,15 @@ const figurePath = (figure: Figure, box: Box, direction: 1 | -1): string => {
return POLYGONS[figure].map((point) => `L${place(point)}`).join("");
};
export type Thread = {
readonly d: string;
/** Where the trace branches or turns: the solder points, in the order the line passes them. */
readonly pads: readonly Point[];
};
/**
* The whole line. It starts at `start` in the left margin, visits the stops in
* The whole line, laid out like a trace on a circuit board: straight runs and
* 45 degree bends. 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.
*/
@@ -104,8 +111,9 @@ export const threadPath = (thread: {
readonly end: Point | null;
readonly left: number;
readonly right: number;
}): string => {
}): Thread => {
const { left, right } = thread;
const pads: Point[] = [thread.start];
let d = `M${round(thread.start[0])} ${round(thread.start[1])}`;
let side = left;
@@ -116,16 +124,53 @@ export const threadPath = (thread: {
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${from} ${y - CORNER}L${from + direction * CORNER} ${y}` +
`L${foot} ${y}` +
figurePath(stop.figure, stop.box, direction) +
`L${to - direction * CORNER} ${y}Q${to} ${y} ${to} ${y + CORNER}`;
`L${to - direction * CORNER} ${y}L${to} ${y + CORNER}`;
pads.push([from, y - CORNER], [foot, 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)}`;
const bend = Math.abs(x - side);
d += `L${side} ${round(y - bend)}L${round(x)} ${round(y)}`;
pads.push([round(x), round(y)]);
}
return d;
return { d, pads };
};
/** Extra marks that make a figure read as a component: pins, as lines in the 100 x 100 box. */
export const FIGURE_PINS: Readonly<
Record<Figure, readonly (readonly [Point, Point])[]>
> = {
diamond: [],
hexagon: [],
circle: [],
triangle: [],
screen: [20, 35, 50, 65, 80].map((x) => [
[x, 36],
[x, 24],
]),
square: [38, 54, 70, 86].flatMap((y) => [
[
[10, y],
[-2, y],
],
[
[90, y],
[102, y],
],
]),
};
/** Where the label sits inside each figure, in the 100 x 100 box. */
export const FIGURE_CENTRE: Readonly<Record<Figure, Point>> = {
diamond: [50, 55],
hexagon: [50, 57],
screen: [50, 68],
circle: [50, 55],
square: [50, 60],
triangle: [50, 74],
};